Skip to content

gateware+libm2sdr: hardware timed-TX gate (deterministic on-air timing)#148

Draft
simonerni wants to merge 1 commit into
enjoy-digital:mainfrom
simonerni:pr/timed-tx-gate
Draft

gateware+libm2sdr: hardware timed-TX gate (deterministic on-air timing)#148
simonerni wants to merge 1 commit into
enjoy-digital:mainfrom
simonerni:pr/timed-tx-gate

Conversation

@simonerni

Copy link
Copy Markdown
Contributor

Hardware timed-TX gate (deterministic on-air timing)

Adds a hardware timed-TX gate to the FPGA header extractor: each TX DMA buffer is
held until the FPGA time reaches the buffer's header air-time, then released — so the
ns timestamp the host writes is the on-air time, deterministic to the 10 ns time grid.
This replaces host-side ring-lead / pacing workarounds with a single, exact mechanism.

Semantics (per-buffer, matches UHD has_time_spec / Soapy SOAPY_SDR_HAS_TIME)

Header timestamp Behavior
0 (untimed) Transmit immediately — continuous streaming, zero added latency. Existing/SoapySDR clients are unaffected.
non-zero (timed) Gated to that air-time. Released the cycle FPGA_time + tx_offset reaches it.
non-zero, already past on arrival Dropped whole (the RFIC airs zeros) and counted as a TX underflow.
  • timestamp == 0 is a safe sentinel: the FPGA time counter is 0-based at power-on, so
    air-time 0 is always in the past — no legitimate timed frame targets it.
  • A multi-buffer timed burst stamps only its first buffer; the rest stream contiguously
    behind it (matches "time applies to the first sample of the burst"). Air-time is quantized
    to the 10 ns grid (round-to-nearest); libm2sdr floors any non-zero request up to one tick
    so it can never collide with the untimed sentinel.

Host API (libm2sdr)

  • m2sdr_set_tx_offset(dev, ns) — loopback-calibrated TX-pipeline compensation, so a
    calibrated "transmit at X" puts the signal on the air at X.
  • m2sdr_get_tx_underflow(dev, &count) — the on-air TX underflow counter (timed frames that
    missed their air-time), distinct from the DMA-ring underflow in m2sdr_get_stats().

The gate FSM handles both the 64-bit and 128-bit header widths; the 128-bit path is generic
plumbing that stays dormant until a wide datapath instantiates it.

Validation (TX→RX loopback)

  • Continuous untimed tone: clean (2T2R @ 61.44: 33 dB, 0 TX underflows, cadence +0.1 ppm).
  • Timed marker tagged at X airs at X + a deterministic pipeline latency
    (1250 ns @ 30.72, 358 ns on the 128-bit @ 122.88 path), std ≈ 11 ns — sub-sample.
    Programming tx_offset drives the RX-observed time to X (residual ≈ 6 ns, within the grid).
  • Too-late frames drop and increment the underflow counter.
  • After an underflow the stream re-anchors on the buffer grid — the loss is an integer
    number of whole DMA buffers (reported, not silent), never a sub-buffer sample offset.

Tests & CI

  • test/test_header.py — the gate FSM proven hardware-free in simulation (13/13:
    untimed passthrough, release-on-time, hold-then-release, drop-late + underflow, tx_offset,
    recover-after-drop; 64- and 128-bit).
  • scripts/timed_tx_selftest.c — hardware air-time accuracy + calibration + drop harness.
  • scripts/latency_probe.c — RX-delivery latency measurement.
  • CI: cppcheck analyze-libm2sdr clean, pytest green, kernel + user + SoapySDR builds clean.

The FPGA header extractor now holds each TX DMA buffer until FPGA time + tx_offset
reaches the buffer's header air-time, then releases it, so the header ns-timestamp IS
the on-air time, deterministic to the 10 ns time grid.

Per-buffer semantics match UHD/Soapy: header timestamp 0 = untimed = transmit
immediately (continuous streaming, zero added latency); nonzero = gated to that
air-time; a frame that arrives past its air-time is dropped whole (the RFIC airs zeros)
and counted as a TX underflow (CSR header_tx_underflow, m2sdr_get_tx_underflow). A
multi-buffer timed burst stamps only its first buffer; the air-time is quantized to the
10 ns grid, with m2sdr_set_tx_offset for loopback-calibrated TX-pipeline compensation.
The gate FSM handles both header widths; the 128-bit path is dormant until a wide
datapath instantiates it.

Validated on the TX->RX loopback: continuous untimed tone clean; a marker tagged at X
airs at X + a deterministic pipeline latency (1250 ns @30.72; 358 ns on the 128-bit
@122.88 path), calibrated to X via tx_offset; too-late frames drop and increment the
underflow counter; the stream re-anchors on the buffer grid after an underflow (no
sample offset). The gate FSM is covered hardware-free by test/test_header.py (13/13,
64- and 128-bit). New harnesses: scripts/timed_tx_selftest (air-time accuracy +
calibration + drop) and scripts/latency_probe (RX-delivery latency).

CI: cppcheck analyze-libm2sdr clean, pytest passed, user + SoapySDR builds clean.
@simonerni
simonerni marked this pull request as draft July 14, 2026 09:15
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.

1 participant