txengine is split into layers that meet only at two small interfaces in
txengine-core: a modulator produces RF control samples, an RF sink
emits them. The engine in the binary connects one to the other. Adding a
transmission mode or a hardware platform means implementing one side.
audio file / stdin GPIO pin
│ ▲
┌──────▼───────┐ ring ┌──────────────┐ f32 ┌────────┴───────┐
│ txengine- │ ──────► │ Modulator │ ────────► │ RfSink │
│ audio thread │ (rtrb) │ (WFM: MPX + │ samples │ (RP1, BCM, or │
│ decode+resamp│ │ RDS) │ │ file) │
└──────────────┘ └──────▲───────┘ └────────▲───────┘
│ RenderContext │ poll / write
┌──────┴────────────────────────────┴──────┐
│ engine (SCHED_FIFO thread): keeps the │
│ sink's buffer full, handles control │
└──────────────────────────────────────────┘
| Crate | Contents |
|---|---|
txengine-core |
Modulator and RfSink traits, SinkInfo, RenderContext. No dependencies. |
txengine-dsp |
Allocation-free DSP blocks: Kaiser FIR design, linear-phase block FIRs, polyphase interpolator, an elliptic high-pass (second-order sections), pre-emphasis, pre-emphasis-aware look-ahead limiter, phase accumulator and sine table, small optimiser. |
txengine-rds |
EN 50067: group coding and CRC, character set, scheduler/encoder, the physical layer (RdsModulator), and an independent decoder used by tests and analyze. |
txengine-fm |
The wide-band FM mode: MpxGenerator (stereo multiplex) and WfmModulator (the Modulator implementation, control commands). |
txengine-nfm |
The narrow-band FM mode: BasebandGenerator (voice and squelch signalling to frequency deviation), CTCSS tones and DCS words (subaudio), and NfmModulator. |
txengine-am |
The AM broadcast mode: EnvelopeGenerator (programme to carrier envelope) and AmModulator. |
txengine-audio |
Input decoding (symphonia), resampling (rubato), the lock-free ring to the transmit thread, clock-drift steering for live input; the programme the modes share (Programme, AudioFeed). |
txengine-hal |
Hardware: platform detection, MMIO, DMA memory providers, the BCM283x/BCM2711 backends and the RP1 (Pi 5) backends (FM and AM each), amplitude by pulse width (am), the shutdown guardian; usbfs (USB without libusb) and the Pico backend (planning, RfSink). |
txengine-pico-protocol |
The USB protocol between txengine and the Pico firmware, and the arithmetic both sides share: the sigma-delta modulator of FM, the pulse quantiser of AM (am). no_std, no dependencies. |
txengine |
The command-line program: engine loop, control FIFO/socket, file sinks, the modes' probe and tone, devices, the hardware backends and their selection (hw/, also transmitting on a harmonic), the programme input options every mode shares, and per mode its options, set-up and tools (WFM: analyze). |
firmware/pico |
The RP2040/RP2350 firmware (embassy-rp), a cargo workspace of its own for the bare-metal targets. |
A sink accepts a stream of f32 samples of one kind (SampleFormat): the
instantaneous carrier frequency offset in Hz, held for one sample period
(FrequencyOffset, FM), or the carrier's amplitude, 0 to 1 of the
strongest carrier it makes, interpolated between samples (Amplitude,
AM). It describes itself once in SinkInfo:
sample_rate: the exact nominal rate the hardware runs at (derived from its dividers and the nominal crystal frequency, e.g. 50 MHz / 200 on the Pi 5).buffer_len,realtime.zero_order_holdandresponse_db: the output stage's frequency response (a hold, plus any measured correction such as the PLL loop of the Pi 2 and Zero 2 W). Modulators may equalise it.max_offset_hz,resolution_hz,carrier_hz.max_clock_error_ppm: how large a crystal error the sink can correct.paced_by_carrier: the carrier itself paces the samples (each lasts a whole number of carrier cycles: AM), so correcting the carrier corrects the sample clock too (see "Crystal error").
poll() reports how many samples can be written, how many the hardware has
consumed (consumed, an absolute sample index) and the underrun count;
write() appends; start()/stop(). set_clock_error_ppm() tells the
sink how far its crystal is off, and the sink retunes the carrier for it
(see "Crystal error" below).
configure(&SinkInfo) adapts to the sink, render(&RenderContext, &mut [f32]) produces samples, command() handles control lines, and
take_meters() reports levels and limiting for the status line. The
RenderContext carries the absolute index of the first sample, the rate at
which the samples actually leave the transmitter (sample_rate: the sink's
nominal rate corrected for its crystal error, so it can change slowly while
transmitting) and, for real-time sinks, the wall-clock time at which the
first sample will leave the antenna (emit_time_unix). Everything
time-related builds on that mapping: the RDS clock-time group is scheduled
so that it ends at the minute edge of the emission time, not the render
time, and the WFM modulator keeps its subcarriers and the programme's
playback speed true to the actual rate.
A new transmission mode (e.g. a digital mode) implements Modulator. A
mode that needs a different kind of sample (I/Q, say) adds a SampleFormat
variant, and sinks that can produce it advertise it.
On the command line every mode is a command with its own subcommands
(txengine wfm tx | render | analyze | probe | tone,
txengine nfm tx | render | probe | tone,
txengine am tx | render | probe); probe shows the plan for the mode
and tone transmits raw tones from it (both in mode.rs), and only
devices needs no mode. A mode's options are a clap Args struct that
implements Mode (txengine/src/mode.rs): what it needs from the
transmitter (hw::Output: frequency modulation over a range, or
amplitude), a check of the options, and building its modulator for the
opened output (its SinkInfo: rate, real time or not, and the buffer the
programme must fill at the start). The generic Tx<M> and Render<M>
(txengine/src/cli.rs) add the options every mode shares (hardware,
control, file output), the programme input options are one flattened
struct (txengine/src/programme.rs), and mode::transmit and
mode::render run any mode. The modes live in txengine/src/wfm/,
txengine/src/nfm/ and txengine/src/am/.
All per-sample work happens in blocks of up to 512 output samples, one stage at a time; this keeps the inner loops short, which matters on the in-order Cortex-A7/A53 cores. On a Pi 2 at 900 MHz, transmitting stereo + RDS at 195 kS/s from a FLAC file takes about 33 % of one core for the transmit thread (28 % without the separate interpolation of the pre-emphasis boost the limiter needs) and 23 % for decoding and resampling the audio.
- Audio rate
fa = fs / round(fs / 48 kHz): gain, mono mix (if not stereo), 15 kHz low-pass (≥ 80 dB from 18.5 kHz), pre-emphasis (first-order shelf fitted minimax to the analogue curve, ≤ 0.06 dB), M/S. With the limiter on, the flat programmexand the boost the pre-emphasis adds,d(pre-emphasised =x + d), are kept apart. The gain includes a drive correction: minus the rise of the power of pink noise (20 Hz – 15 kHz) through the pre-emphasis, 4.3 dB at 50 µs, 6.8 dB at 75 µs. BS.412 limits the power of the pre-emphasised multiplex; with the correction a programme no brighter than pink noise (music rarely is) keeps the MPX power it has without pre-emphasis. - Interpolation to
fs: polyphase FIR, ≥ 120 dB image rejection. The first images (fa ± f) lie on the stereo subband, and a stereo receiver demodulates them into the audio band at|fa − 38 kHz ± f|(13 kHz → 1 kHz atfa= 50 kHz), where the de-emphasis does not take back the up to 13.7 dB the pre-emphasis gave the original tone; with 90 dB these products reached −80 dB. The branches are evaluated together with independent accumulators. With the limiter,xanddare interpolated separately (four channels in stereo). - Limiter, per audio-rate frame on the interpolated samples, so it
bounds the true peak of
M + S·sin 2φ, which lies between L and R (max(|L|, |R|) = |M| + |S|). Two gains: the HF gainhscales only the boost, the peak gaingeverything: outputg·(x + h·d).his the largest gain with|x + h·d| ≤ max(T, |x|)for every sample of L and R: a high-frequency overload is resolved by reducing the pre-emphasis for a moment, which a receiver hears as less treble rather than the whole programme ducking. After that every sample is withinmax(T, |x|), sogfollows from the flat peak alone and only acts when the flat programme is over full scale. Each gain = box-average over the look-ahead (1.5 ms) of the sliding minimum of the required gain, with a release (HF 50 ms, peak 80 ms); interpolated linearly across each frame. The construction guarantees the threshold for every sample. - Composite:
a·(M + S·sin 2φ) + p·sin φ + r·rds·sin 3φ, where φ is a single 19 kHz phase accumulator (64-bit, 40 fractional bits). sin 2φ and sin 3φ are computed from sin φ and cos φ, so all subcarriers are exactly phase locked; the RDS bit clock is the same accumulator divided by 16. - Output equaliser: a linear-phase FIR (9 or 11 taps), least-squares
fit to
1 / (sinc(f/fs) · measured response)up to 62 kHz. - Safety clip at the maximum deviation, peak metering, and the MPX power (BS.412) over the last 60 s, in one-second buckets.
RDS: the data are differentially encoded and each bit produces a biphase
symbol filtered by H_T(f) = cos(π f td / 4), whose impulse response is used
in closed form. The symbol is truncated to ±6 bits; the waveform of one bit
depends on 12 symbols, which are tabulated in three groups of four (16 sign
patterns each). Once per bit the waveform of that bit is assembled from
three table rows, and each output sample is one interpolated look-up. The
level is normalised to the all-zero reference sine (EN 50067 1.3).
Voice in a 12.5 or 25 kHz channel, with a CTCSS tone or a DCS code for
the receivers' squelch (txengine-nfm), in blocks of up to 512 output
samples like the other modes. The samples are the deviation in Hz, as in
WFM.
- Audio rate
fa = fs / floor(fs / 16 kHz)(16.7 kHz at 250 and 166.7 kS/s, 17.3 kHz at 190 kS/s): mono mix, gain, high-pass, pre-emphasis, low-pass.- The high-pass is an 8th-order elliptic filter (four second-order
sections in
f64,txengine-dsp::iir): the voice band from 300 Hz within 0.1 dB, 60 dB down from 254 Hz, the highest standard CTCSS tone, to DC. Its analogue prototype is tabulated (the tests check its response), made a high-pass at the pre-warped edge and digital by the bilinear transform for whateverfathe output gives. - The pre-emphasis is the WFM mode's first-order shelf (750 µs: 6 dB/octave from 212 Hz), normalised to 1 at 1 kHz: a full-scale 1 kHz tone deviates the full voice deviation.
- The low-pass at the audio bandwidth (3 kHz) is linear phase, 60 dB from 10 % above it.
- The high-pass is an 8th-order elliptic filter (four second-order
sections in
- Interpolation to
fs(polyphase, 80 dB): the images of the audio would widen the signal beyond its channel. - Limiter (
PeakLimiter, as in AM): the true peaks of the voice within ±1; look-ahead 1.5 ms, release 80 ms. It neither clips nor splatters. - Deviation: voice ×
Dv+ sub-audio ×Ds,Dv = D − Ds(Ds15 % ofDby default), so the sum never exceedsD. The CTCSS tone is a phase accumulator; DCS sends its 23-bit Golay word (polynomial0xC75, data100and the code's 9 bits, lowest bit first) at 134.4 bit/s with raised-cosine transitions one bit long, 52 dB less power above 300 Hz than below. Both followRenderContext::sample_rate, so the crystal correction keeps the tone and the bit rate true.
The output's sample-and-hold and the PLL responses the sinks report do not matter below 3 kHz (under 0.01 dB), so nothing is equalised.
Double sideband with full carrier (A3E), as on long, medium and short wave
(txengine-am). The envelope generator works like the multiplex: blocks of
up to 512 output samples, one stage at a time.
- Audio rate
fa = fs / floor(fs / 24 kHz)(25 kHz at 100 kS/s): mono mix, DC blocker (10 Hz), gain, low-pass at the audio bandwidth (4.5 kHz for the 9 kHz channels of ITU regions 1 and 3, up to 10 kHz; 60 dB from 10 % above it). - Interpolation to
fs(polyphase, 80 dB). - Limiter (
PeakLimiter): the multiplex's construction for one channel, on the interpolated samples, so the true peaks stay within ±1; look-ahead 1.5 ms, release 80 ms. - Envelope
c·(1 + m·x)with the carrierc = 1 / (1 + m): the positive peaks reach the transmitter's full output, the negative ones never cut the carrier off.
The sink turns the envelope into the transmitter's waveform; for a PWM
output, txengine-hal/src/am.rs: one pulse per carrier period, centred on
the same instant of every period, so that the fundamental's amplitude is
sin(π w / P) (width w, period P) and its phase does not depend on the
width. Every carrier cycle gets its own width: the envelope is interpolated
linearly across the cycles of each sample (response sinc²(f / fs),
reported in response_db), mapped through an arcsine table and quantised
by a second-order error-feedback loop, which moves the quantisation error
from the carrier towards ±fc/2 (in a simulation of 999 kHz from 400 MHz,
89 dB below the carrier within ±5 kHz). The loop runs in fixed point: on
an in-order Cortex-A53 its float version took ~60 ns per carrier cycle
(of 1 µs at 1 MHz), mostly waiting for its own results, the integer one
~29 ns.
--device BACKEND[:ARGS] picks one (txengine/src/hw/): every backend
implements hw::Backend (its name, the devices of its kind present now,
a check of the options that apply to it, the name of its instance lock,
open returning an RfSink, and probe) and is listed in hw::BACKENDS.
Without --device, txengine uses the only device any backend finds.
Options that only some backends use stay flags and are rejected by the
others (--pll on a Pico, for example).
The Raspberry Pi backends share the same structure: plan the clocks,
snapshot every register they will touch, start a guardian (a process
forked before any thread exists; when the transmitter's pipe closes, for
whatever reason including kill -9, it stops the DMA and restores the
snapshot), then run a DMA ring, paced by hardware, that writes one
frequency word per sample into a PLL or clock divider register (FM) or a
pulse width per carrier cycle into a PWM (AM). A clock generator that is
already running belongs to something else (the Pi 2's Ethernet and USB
chip on GPCLK1, the Zero 2 W's WiFi on GPCLK2): FM refuses it before
touching anything, unless --force-gpclk takes it over until the
transmitter stops.
Every planner that modulates a PLL (RP1, BCM, the Pico's PLL method)
prefers, above its other preferences, divisions that keep the carrier clear
of the points where the PLL makes a spur next to it: the VCO at a half
multiple of the frequency its phase detector compares at, and on the Pico
also the carrier at some fractions of it (boundary::Spurs; the module
has the measurements). The clearance it aims for is an eighth of the
largest offset planned (the quiet passages) plus 30 ppm (the crystal
correction); a plan that falls short of the first part logs a warning (and
txengine wfm probe shows it).
Each frequency-modulating backend makes carriers up to a limit of its
clock outputs (MAX_CARRIER_HZ: 400 MHz on the BCM boards, 1 GHz on the
RP1 and the Pico). Beyond, --harmonic N opens the backend at f / N
with 1 / N of the mode's largest offset, and wraps its sink in
hw::harmonic::HarmonicSink, which reports the carrier, offsets and
resolution N times larger and divides every sample by N on its way to
the hardware: the N-th harmonic of the pin's square wave then carries
the modulation as planned. The crystal correction passes through
unchanged (it is relative). AM refuses it: a pulse's harmonics do not
follow its width as the fundamental does.
- The carrier is
pll_audio(orpll_video) →clk_gp0..2→ GPIO. The PLL's 24-bit fractional feedback divider is the modulation input; post dividers and the clock generator divide down to the carrier (up to 1 GHz the PLL drives the pin undivided). The plan is verified with RP1's frequency counter. The fraction only moves within its whole number, so a carrier whose VCO lands near a whole multiple of the 50 MHz reference for every division (near multiples of 50 MHz) cannot be modulated, and the planner says so. The VCO is planned within 1.0–3.0 GHz, down to 0.8 GHz if needed (clocks::VCO_WINDOWS):pll_audiolocked from 0.8 GHz (the feedback divider's minimum) to 4.2 GHz and modulated equally cleanly from 1.46 to 2.91 GHz, while at 4.07 GHz its phase noise 1 kHz out was 9 dB higher. Within them the planner prefers no clock generator division and the fraction far from whole numbers. - Pacing: an RP1 DMA "tick" (xosc / N, e.g. 50 MHz / 200 = 250 kS/s) drives
the handshake of one channel of the Synopsys AXI DMAC. Samples live in
host memory pinned through io_uring (anonymous pages, long-term pinned,
cache maintenance done explicitly), reached by the DMAC at
phys + 0x10_0000_0000over PCIe. - The ring is a circular linked list of blocks (1024 samples each). A block is handed to the hardware by setting the VALID bit in its LLI; the DMAC clears it when it writes the LLI back after the block, which is also how txengine tracks progress. If the DMAC reaches an LLI that is not valid it stops and never re-fetches it. txengine detects any standstill of more than 2 ms (a running engine moves every 4 µs), counts an underrun, parks the carrier at the centre frequency, and re-arms the channel at the first block whose LLI is still valid.
- The kernel runtime-suspends the DMAC when its own clients are idle; the health check re-enables it. Channel interrupts are kept disabled so the kernel's handler never sees our channel.
- PCIe ASPM is disabled on the RP1 link while transmitting: waking the link from L1 at a block boundary can take long enough to lose a tick.
- The carrier is a PWM0 channel in phase-correct mode (GPIO 12, 13, 18 or
19) from
pll_audio→pll_audio_pri_ph→clk_pwm0, at up to 400 MHz (RP1's datasheet gives 150 MHz; the counter and the pin follow at 400). The channel counts up and down over2nclocks, one carrier period, and its output is high while the count is below the duty value: one pulse of2·dutyclocks centred on the count's turning point. The planner picks the largestnwhose clock2n · fcthe PLL reaches with room for the crystal correction in its fraction (999 kHz:n= 200, 399.6 MHz). - The duty values come from the channel's 16-word FIFO, one per period, which an AXI DMA channel refills on the PWM's request: the carrier paces the samples. The ring and its bookkeeping are the FM backend's (LLIs in pinned memory, VALID bits, stall detection and re-arming), with one word per carrier cycle; the blocks are pages or fractions of a page so that the buffer comes close to the length asked for. Channels 0 and 1 (8-beat FIFO, bursts of 8 words) sustain 6.25 M words/s, the others 3.8 (both measured), which limits the carrier to 6.2 MHz. A sample lasts the whole number of carrier cycles that comes closest to 10 µs.
- The crystal correction scales the PLL's feedback divider, which moves the
carrier and the sample clock together (
paced_by_carrier). - The guardian restores the PLL,
clk_pwm0, the PWM block, the pin and the link power states, as in the FM backend.
- The carrier is a PLL (A, C or D, or on the BCM2835/6/7 H, the HDMI PLL,
through its AUX channel) → PER divider → GPCLK → GPIO, or a GPCLK
fractional divider from PLLD (
clkdiv). Clock generators divide by at least 2 (cprman::MIN_DIVI): set to 1, GPCLK0 and the PWM clock divide by 4096, from any source (measured). GPCLK2 has no MASH control and no frequency counter input (cm::has_mash,cm::tcnt_gp): it divides by whole numbers only (noclkdiv), and its carrier is neither verified at start nor watched. The planner scans every clock generator to find out who else uses each PLL (Pll::users: clocks that select its PER channel, CORE channels that a clock selects, and display or camera channels the firmware has not parked at the divider 256) and refuses changes of more than 3 % (or any change of a PER divider that others use): PLLC clocks the VPU (and through it the SD card host, SPI, I2C and the mini UART) and EMMC (the WiFi's SDIO host), PLLD the PL011 UART and HDMI; the UARTs tolerate about ±3 % of baud rate error, and the SD and SDIO buses run at their 50 MHz maximum. A PLL whose only users are the GPU's video and 3D blocks (h264,isp,v3don its CORE channel: normally PLLA) may move down by up to 25 % (cprman::GPU_PLL_MAX_DECREASE): from 2250 MHz −12 % reach every carrier in 87.5–108 MHz, where ±3 % reaches 76 % of the band (PLLC at ±3 %: 50 %), and −25 % the narrow-band FM channels (all of CB, the amateur bands and marine VHF up to 225 MHz; in 148–174 MHz all but three spots where every division puts the fraction at a whole number). The planner takes the least change that keeps the carrier clear of the PLL's spurs (above): in the FM band −12 % at most, but for 21 of 2051 carriers (down to −22 %). The carrier stays at or below 400 MHz, above which the generators' output fades (fm::MAX_CARRIER_HZ). A PLL nothing else uses is preferred, and planned anywhere the hardware takes it, mid-range: on the BCM2835/6/7 0.8–2.6 GHz with the feedback pre-divider engaged (cprman::BCM283X_VCO_RANGE; PLLA locked from 0.4 to 2.8–3.0 GHz with or without it, where Linux keeps 0.6–2.4 GHz and engages it above 1.75 GHz), which halves the fraction the modulation sweeps (from PLLH without it 25 FM carriers could not be modulated at ±100 kHz). A PLL that is started gets Linux's loop settings. The clock generators take at most 1.5 GHz (cprman::MAX_PER_HZ; they were exact up to 1.6–1.8 GHz). Otherwise, on the BCM2836/7, PLLA, then PLLC, then PLLH (cprman::pll_preference). - PLLH is shared too where the firmware runs the composite video encoder
from its AUX channel (
vec, at 108 MHz: the Pi 2 and Zero 2 W without a display); txengine then keeps its divider and moves it by 3 % at most. Its fraction takes the DMA engine's writes more slowly (179–181 k/s on a Zero 2 W, 183–186 k/s on a Pi 2, PLLA 228–231 k/s: 152 kS/s for FM). It gets PLLA's loop gain (KI 5; at the firmware's KI 2 it lifted the stereo subcarrier by 0.4 dB) and its own measured response (fm::PLLH_RESPONSE_DB, andPLLH_LOW_VCO_RESPONSE_DBnear 864 MHz without the pre-divider, where it droops slightly; measured against PLLA's, with which its stereo subcarrier then agrees within 0.012 dB). It comes last in the preference, for the carriers the others cannot make, and reaches more of them: of 225–400 MHz all but 9 of 1751 carriers, where PLLA's room misses 190. - The BCM2711 (Pi 4) differs. Its firmware runs the VCOs at 2.6–3.25 GHz
without a feedback pre-divider: ANA1 bits 13–14 select a VCO range
instead, set differently on each PLL. txengine leaves them and plans
within what they allow (
cprman::bcm2711_vco_range: PLLC locked from 1.08 GHz to beyond 6.4 GHz with bit 14, from 1.25 to 3.8 GHz with neither, from 1.05 to 2.2 GHz with bit 13 alone), and does not start a PLL. On a Pi 4 the VPU runs from PLLA and PLLC's channels are parked: PLLC is idle and planned within 1.4–4.0 GHz (it modulated as cleanly from 1.46 to 4.66 GHz as at 2.62 GHz), which reaches every FM carrier but those within 140 kHz of 108 MHz (twice the 54 MHz crystal). Its GPCLKs take at most 600 MHz (cprman::max_gpclk_input_hz): fed faster, from 675 MHz, they divide wrongly, even divisors first (from 864 MHz most give 0.22 MHz), so the carrier stays at or below 300 MHz, andclkdivfrom PLLD_PER (750 MHz) is refused; the PWM and PCM clocks take 1.3 GHz. Its PLLs made no integer-boundary spurs. It keeps the firmware's loop (KI 2, KP 3), whose modulation response is flat within 0.04 dB to 45 kHz. - Loop gain of PLLA and PLLC: the firmware and Linux run every PLL with the loop
filter gains KI 2, KP 8, KA 2 (
A2W_PLLx_ANA1/3), which give PLLA a loop bandwidth of about 125 kHz (natural frequency fitted to the measured modulation response, damping 1.6). Inside the multiplex band that loop neither suppresses the VCO's phase noise well (a hump at 10–30 kHz offset; on a Pi 2 −91 dBc/Hz at 10 kHz, which limited the stereo SNR to 44 dB) nor follows the modulation flatly (+0.4 dB at 38 kHz, +0.57 dB and 6° of lag at 67 kHz). While modulating PLLA or PLLC of the BCM2836/7, txengine raises the integral gain to KI 5 (fm:: MODULATION_KI): about 600 kHz of bandwidth, −101 dBc/Hz at 10 kHz, and a response within 0.12 dB and 0.25° up to 67 kHz. The wider loop moves its noise peak out to 300–600 kHz offset (+1 dB in the 200 kHz channels at ±400 kHz); KI 6 gains 1 dB of SNR for 3–5 dB more there, and KI 7 also raises the noise close to the carrier. The lock behaviour (400 retunes of ±150 MHz, 400 deviation-sized steps) is the same for KI 2–7. The firmware's gain is restored with the rest of the PLL. On the Zero 2 W the noise has another source (it does not change with any loop gain), but the flatter response is the same. PLLA and PLLC are the same design: at the same VCO frequency their responses agree within 0.01 dB at KI 2 and at KI 5, and at KI 5 PLLA's response is the same (±0.02 dB) from 1927 to 2365 MHz, so one table per SoC compensates both PLLs at any VCO frequency. - Pacing: the PWM (or PCM) FIFO's DMA request. Each sample is two control
blocks: write the frequency word, then write a dummy word to the FIFO.
Writes to the PWM registers must pass a slow clock-domain synchroniser,
so the set-up confirms each write by reading it back or waiting for the
status it causes, checks
STA.BERR, and the pacer clock is kept between 5 and 25 MHz; after starting, the DMA position must advance or the pacer is configured again. The pacer clock divides PLLD_PER by an integer when that hits the rate within 1 ppm, otherwise by a fractional divider (MASH 1, at most 2 ns of jitter), which hits any rate within a fraction of a hertz. - The DMA engine's write rate to the PLL registers is limited (≈ 231 k
writes/s on the Pi 2 and the Zero 2 W, 646 k on the Pi 4), so the sample
rate is chosen from a measurement at start-up: the highest even multiple
of 19 kHz within 85 % of it and at most 250 kS/s (
fm::RATE_STEP; the Pi 4: 228 kS/s), but no less than the lowest a multiplex takes, 152 kS/s, while that is within 90 % (PLLH, whose fraction takes 179–186 k writes/s, ran as cleanly at 90 and 95 % as at 84 %), which puts the images of the hold onto harmonics of 19 kHz, where a switching stereo decoder makes no tones of them. The achieved rate is checked continuously. - The PLL registers sit behind the slow A2W bus, which the firmware also
uses to change the ARM clock (PLLB). If a firmware write meets one of the
DMA engine's, it lands in the modulated PLL's registers instead (PLLB's
registers are PLLA's plus 0xe0; on a Zero 2 W, PLLA's control register
read 0 and its channel dividers were overwritten). While a PLL is
modulated, the cpufreq governor is therefore set to
performance(and restored afterwards, also by the guardian). If the PLL is changed anyway (e.g. by a firmware thermal throttle) it is repaired, including the channels other clocks use. It is watched through the clock manager only, every 20 ms: its lock bit, and the carrier, which the frequency counter measures in the background (gates of 5 and 10 ms alternate, so that a stopped clock's stale count does not fit). A count more than 0.5 % off (one feedback divider step is at least 0.8 %) is checked against the PLL's registers before anything is repaired, since another user of the counter could also cause it. The A2W registers are not polled: each CPU read of one delays the DMA engine's next fraction write by about 3 µs, and the samples after it catch up through the pacer's FIFO. On a Pi 2 the stereo pilot alone turned one read into a click of about 450 Hz of deviation; reading the PLL's nine registers once a second (as txengine did before) delayed the sample timeline by 27 µs each time, an audible click every second. Forced changes of NDIV or of the PER divider, which leave the PLL locked, were repaired after 19-36 ms. - Every register and DMA-memory access is a single 32-bit
ldr/strfrom a general-purpose register (mmio::load32/store32). Plain volatile accesses may be compiled to floating-point register loads when the value is converted to a float next, and on the BCM2837 those return the word 4 bytes below for addresses ≡ 4 (mod 8); merged copies would make unaligned stores, which fault on AArch64 Device memory. - The frequency counter that verifies the carrier is read with two gate times: its count register cannot be cleared while the measured clock is stopped, so a dead clock would otherwise read back the previous result.
- Underruns: samples are handed to the engine in blocks of 16. The newest block's last sample leads, through the block's exit control block, which writes the block's sequence number to a progress word, to an idle loop (two control blocks that keep the carrier at the centre frequency). The exits are off the samples' path: between underruns the engine runs the samples' control blocks alone, as before. A parked engine is sent on through a jump control block, as in AM (below), to the first unplayed sample, so an underrun neither replays nor skips samples. Before, the CPU closed the idle loop again only once it saw the engine back in the ring, and took everything written as played when it found the engine parked: on a Zero 2 W each underrun lost about 60 ms of programme, and a stall right after the refill played it twice.
- Memory comes from the VideoCore mailbox (uncached
DIRECTalias), freed by the guardian if the process dies.
- This PWM (PWM0 on GPIO 12 or 18, PWM1 on 13 or 19) has no phase-correct
mode. In mark-space mode each FIFO word is one range period, high for
the word's number of clocks from the period's start. With the range at
half the carrier period (
Rclocks, even) and two words per cycle,R/2 − uandR/2 + u, the cycle's two pulses have a fundamental ofsin(π u / R)with a constant phase:utakes the place of the width inhal::am(a period ofRsteps,R/2steps to the full square wave). Atu = 0the pulses are equal and half a period apart, all second harmonic, which at amplitudeais(1 − a²) / aof the carrier: +2.8 dB at the carrier of 90 % depth. A word missing on the way swaps the pulses, which shifts the carrier by half a period. - The PWM clock divides a PLL's PER channel by an integer (a fractional
divider would jitter by a whole source cycle), at most 250 MHz (500 MHz
fails from a range of 4, on a Zero 2 W). The planner (
am::plan) takes the finest steps (R = 2k, the largestkwith4k · fc≤ 250 MHz) that the PLL can make within the limits its users allow (cprman::PllRoom, as for FM) with room for the crystal correction in its fraction, and for those the least change of its VCO; the PLLs are tried in FM's order (cprman::pll_preference). 999 kHz: PLLA from 2250 to 2229.8 MHz (−0.9 %), PER /3, PWM clock /3 = 247.75 MHz,R= 124. The crystal correction writes the PLL's fraction, only when it has moved by most of a unit (each write is a slow A2W transaction). The PWM clock is verified with the frequency counter and watched in the background like FM's carrier; a changed PLL is repaired. - DMA: blocks of up to 512 cycles (1024 words, a page) from VideoCore
memory into the FIFO, in bursts of four words requested while the FIFO
holds fewer than 8, with panic priority. On a Zero 2 W a lite channel
kept the PWM supplied up to 10 M words/s (5 MHz) and missed 1.7 % at
12; 8.4 (4.2 MHz) is allowed. The BCM2711's engine is slower: so fed,
a Pi 4's PWM missed cycles from 1.8 MHz on (2 % at 3.5 MHz), from a
lite channel or a full one. Bursts that do not wait for their writes'
responses, requested from 12 words on, keep it supplied up to 2.2 MHz,
the Pi 4's limit (
am::BCM2711_MAX_CARRIER). After each block a marker control block writes the block's sequence number to a progress word: exact bookkeeping, as the RP1's VALID bits give, and within a block the engine's source address. The newest block links to an idle loop of 16 cycles at the mean amplitude of the samples (an unmodulated carrier: an underrun is silent). A parked engine is sent on through a jump control block, which first closes the idle loop again (a DMA write to the idle block'snext) and then continues at the first unplayed block, so the idle loop never points into the ring when the engine comes back to it. The jump is armed only when the idle loop is found closed and the engine then seen in it: an armed jump stays armed until the engine takes it, and arming it again before the engine has been seen back in the ring would send it into a block already played. - The CPU clock is held (
cpufreq) here too: the firmware's ARM clock changes stall the DMA engine for longer than the words in the FIFO last (8 to 11 of them: 4 µs at 1 MHz), and a PWM without a word holds its output low for the range period. Withschedutilon a Zero 2 W, 0.4 % (500 kHz) to 1 % (3 MHz) of the carrier's cycles went missing, and the phase shifts spread the carrier into a cluster of lines; with the clock held, none. The PWM's gap flag (STA.GAPO1) never showed them in mark-space mode, so the transmitter counts the cycles taken against the time (5 s windows) and warns from 0.2 % missing. - The producer writes each cycle's two words with one 64-bit store
(
mmio::store64, from general-purpose registers, 8-byte aligned): an uncached store costs ~10 ns on a Cortex-A53, 32 or 64 bits alike. - The guardian restores the PLL, the PWM clock and registers, the pin and its pads and the cpufreq governor, and frees the memory.
The Pico is a front end: txengine renders the samples on the computer, the firmware turns them into a modulated carrier. One firmware does all: FM by either method, and AM.
- Link (
txengine-pico-protocol): one vendor interface with a bulk OUT endpoint for the samples (FM:i16in units of the plan's resolution, about 3 Hz; AM:u16amplitudes) and control requests for the rest:Info(whose protocol version must match txengine's; also what AM costs the firmware),Version(the firmware'sgit describe),Configure,ConfigurePioorConfigureAm(the whole clock plan of a method, computed by txengine:txengine-hal/src/pico/plan.rs,pico/am.rs),Start,Stop,Status(the count of samples whose emission has started, underruns, faults),Retune(crystal correction, also before the start) andBootsel. A second interface is the Pico SDK's reset interface, forpicotool reboot -f. On the host,usbfs.rstalks to/dev/bus/usbdirectly (asynchronous URBs for the samples, synchronous control transfers). The firmware wraps embassy-rp's USB driver (usb_driver.rs, until a release has the fixes): a control transfer the host gives up (after a bus error, or cancelled) no longer blocks the device until it is unplugged, and the RP2350's controller keeps the hardware fixes itsDriver::newswitched off. txengine repeats a status read or a retune once after an error on the bus. - Carrier (PLL method):
PLL_USB(the system and USB clocks both run fromPLL_SYSat 144 MHz, USB at 144 / 3), its post dividers andclk_gpout0onto GP21. The PLL compares at the crystal / 2 (6 MHz: a divider step moves the carrier half as far as at 12 MHz); in narrow-band FM at the crystal's 12 MHz where only that is clear of the PLL's spurs (plan::reference_dividers,plan_clearest). The divider from the VCO is the largest the VCO range allows (the smallest steps) among those clear of the PLL's spurs (above), 9–18 in the FM band, 2 or 3 at UHF.clk_gpout0divides only up to 160 MHz of input (it is specified to 150 MHz); passing the post dividers on undivided it puts out up to 1 GHz (measured). - Modulation (PLL method): the feedback divider is integer only, so a
MASH 1-1 (
sigma_delta::Mash11, input in 30-bit fixed point over 0–2 steps) dithers it overbase … base + 4at 3 MHz, interpolating linearly between the samples (the samples last 18 updates, 166.7 kS/s). The sink reports the extrasincof the interpolation and the PLL loop's measured lift of the multiplex band (as f², per chip and reference divider) inSinkInfo::response_db. An odd input keeps a steady carrier from repeating a short pattern. Core 1 runs the modulator alone, in assembly from RAM (15 cycles per update on the Cortex-M0+, 11 on the M33, checked against the Rust version at boot), and writes 5-bit codes, six to a word, into a 4 KB ring. DMA feeds them to a PIO state machine, which prependsFBDIV[11:5]and pushes the 12-bit value; a second DMA channel writes it toFBDIV_INT. The state machine paces the writes (one every four crystal cycles, fromclk_sys, which is locked to the same crystal), and once at start it waits for an edge of the crystal clock (put on GPIO 25 for that moment) so that every write lands at the same point of the reference cycle. Both rules, the four cycles and the write phase, are measured requirements of the PLL (README). On the RP2040 the DMA channels are ping-pong pairs (transfer counts run out); on the RP2350 they run inENDLESSmode, and ACCESSCTRL must let the DMA atPLL_USB. - PIO method (
--synthesis pio,PioConfig, firmwaredirect.rs): the system clock switches toPLL_USBat the bit rate B (240–264 MHz, chosen per carrier byplan::pio_planto keep the bit stream's aliases out of the FM band), with the core voltage at 1.20 V (the RP2350's regulator ignores a new setting, which still reads back, until its software control is unlocked; at the default 1.10 V the cores crash at these clocks) and the flash interface's divider scaled up to keep the flash clock where boot put it (on the RP2040, whose SSI must be disabled for that, core 0 makes the change from RAM while core 1 waits;clock.rs, shared with AM). Core 1 looks up one 32-bit word per 32 bits in a table of 4096 square-wave patterns indexed by the phase at the word's start, advances the phase by the sample's increment (interpolated per word), and DMA feeds a 32 KB ring of words to a state machine that runsout pins, 1every cycle. The M0+ word loop is assembly (11 of the 32 cycles a word lasts; the compiled one took about 25, too many with the per-sample work on top). A starved state machine isFault::Stall, a ring overtaken by the DMAFault::Late. Stopping restores the clock, divider and voltage. - AM (
AmConfig, firmwaream.rs, the quantiser intxengine-pico-protocol::am): the system clock is raised as for the PIO method (to 264 MHz, the most cycles per carrier cycle), and a state machine puts out one pulse per carrier cycle from words of two counts, low then high (side-set, so the counts are exact to the cycle and a pulse may have no width). The carrier isclock · k / 2³²; samples last a whole number of carrier cycles, soRetune(a newk) corrects the carrier and the sample clock together (paced_by_carrier). For every pulse the quantiser chooses the width and centre nearv = a − (2E₁ − E₂),Ethe complex errors of the last pulses' phasors, which it computes from their integer edges (a 4096-entry sine table corrected to first order): second-order noise shaping of the carrier's complex envelope, so that the edges can be rounded to clock cycles although the period is not a whole number of them. Rare paths keep the loop from winding up (v pointing backwards: a pulse a quarter period off; beyond the full output, which sits 10·(π/P)² below the square wave for the noise shaping's room). On the RP2040 the quantiser is assembly (Rust spilled to the stack, 280 cycles a pulse): core 1's interpolators turn a phase into its table entry's address and remainder, and their spare registers hold the errors, the amplitude step and constants, at one cycle per access on the I/O port; checked against the Rust version at boot. It takes about 120 cycles per pulse and 280 per sample, whichInforeports (with a margin) and txengine plans with: carriers up to 1.76 MHz at 100 kS/s. The RP2350 runs the Rust version: about 131 and 157 cycles on its Cortex-M33, 1.71 MHz. A starved state machine isFault::Stall; an underrun holds the samples' recent mean. - Buffering: core 0 (embassy, USB) puts the samples into a 32768-sample ring; core 1 takes one per sample period and counts underruns (a missing sample holds an FM carrier at its centre, an AM one at its recent mean). Without samples for one second the firmware assumes the host is gone and switches the carrier off.
- Faults: core 0 feeds a one-second watchdog, so a hung firmware
reboots (and the carrier stops; on the RP2350 the firmware sets what the
watchdog resets itself, as embassy-rp's choice would leave the cores
running); the firmware leaves breadcrumbs in a watchdog scratch
register, reported after such a reset in
Info(shown bytxengine probe). Panics and hard faults, on either core, reboot into the USB bootloader: the system clock first leavesPLL_USB(the raised clock) before that PLL is switched off with the carrier, and core 0 stops feeding the watchdog, on which the boot ROM counts the reboot down. - Timing:
pollreads the status with one control transfer (0.1–0.15 ms), whichClockDisciplinebrackets like a register read, so the crystal error is measured and corrected as on the Raspberry Pi (set_clock_error_ppmsends a new centre withRetune).
Implement RfSink in txengine-hal (plan the clocks, provide DMA memory,
build a paced ring, implement poll/write, fill SinkInfo with the exact
rate and the measured response) and a Backend in txengine/src/hw/
(discovery, the options it accepts, opening the sink), and list it in
hw::BACKENDS. Measure the modulation response with
txengine wfm tone and an SDR, and record it in SinkInfo::response_db
if it deviates from a pure sample-and-hold. Plan the clocks from the
nominal crystal frequency, leave room for a correction of
±MAX_CLOCK_ERROR_PPM, and implement set_clock_error_ppm.
On every supported board the carrier synthesiser and the timer that paces
the samples derive from one crystal, so a crystal that runs e fast has
two effects with the same e: the carrier is too high, and the samples
leave too fast, which raises the pilot, the subcarriers and the playback
speed. One measured error therefore drives both corrections, which is why
there is a single --ppm (and not one for the carrier and one for the
sample rate):
- Carrier:
RfSink::set_clock_error_ppm(e). The backends plan from the nominal crystal frequency and scale the whole feedback divider (integer and fraction) of the modulated PLL by1 / (1 + e), or the clock divider's source frequency by1 + e; the frequency word of each sample is computed with the correction from then on. - Timeline: the engine passes
RenderContext::sample_rate = fs · (1 + e)to the modulator. The WFM modulator retunes its 19 kHz phase accumulator (phase continuously; the subcarriers and the RDS bit clock follow) and asks the audio input to resample by1 + emore, so the programme plays at its true speed.
The hardware sample clock is never changed: the output buffer is refilled
from the hardware position as before, and a live input's buffer is held
half full by its drift loop, which absorbs whatever difference remains
between the source's clock and true time. (Changing the hardware rate
instead, as PiFmAdv's --ppm does, makes a live input over- or underrun
unless the input follows.)
Except where the carrier paces the samples (SinkInfo::paced_by_carrier,
AM): there the carrier correction corrects the sample clock as well, the
samples leave at the nominal rate in true time, and the engine passes the
nominal rate to the modulator. The estimator must not see those
corrections, or it would measure an error that the correction already
removed and undo it: every poll's position is mapped to the position the
sink would have reached uncorrected (each sample emitted under a
correction c counts as 1 + c samples), and the fit runs on that.
The error is measured by ClockDiscipline, which wraps RfSink::poll:
each poll reads the hardware position bracketed by two readings of
CLOCK_MONOTONIC_RAW. A TimeReference turns the readings into
observations "sample n was emitted at time t"; the RateEstimator keeps
the most precise observation of every second and fits the time error
n / fs - t with a least-squares line weighted by exp(-age / 120 s)
(window 12 minutes); its slope is e. A fit is used once it spans 20 s
with a standard error below 0.2 ppm, which takes about 25 s. Underruns
interrupt the timeline, so the fit starts again after each one (the
correction in effect is kept until the new fit qualifies); single outliers
are dropped. Because the corrections do not act on the measured clock, this
is an open-loop estimate and cannot oscillate. A crystal drifting at a
constant rate is followed with a lag of about 3.5 minutes (1.78 time
constants), e.g. 0.5 ppm behind at 0.15 ppm per minute.
The SystemClock reference is the raw monotonic clock (the CPU's timer)
running at the frequency the NTP daemon has given the system clock (adjtimex
freq and tick, read once a second), with the bracket's middle as the
time and readings that took over 0.5 ms dropped. It does not use
CLOCK_MONOTONIC: systemd-timesyncd and ntpd leave the removal of time
offsets to the kernel's PLL, which slews the clock by up to tens of ppm for
many minutes after each NTP poll (a 15 ms offset removed over 512 s starts
at 29 ppm); measured against it, a Pi 5's estimate moved by 20 ppm within
half an hour. chrony slews through the frequency, which this follows; its
slews are small. On the BCM boards the CPU timer and the DMA pacing share
the crystal, so the estimate is exactly the NTP daemon's frequency
correction (negated), smoothed; on the Pi 5 the RP1 has its own crystal and
the estimate combines both. The accuracy is therefore that of the NTP
daemon's frequency estimate. Measured on air with a USRP B210, all boards
with chrony polling a LAN server (16-64 s); the receiver's own error
(3.5 ppm) is taken from the Pi 2 and the Zero 2 W, whose corrected carriers
agree within 3 Hz:
| Board | Crystal error on air | Estimate | Error left (carrier; 1 kHz tone) |
|---|---|---|---|
| Pi 2 | +7.28 ppm | +7.18…7.37 ppm | (reference); +0.1 ppm |
| Zero 2 W | +4.67 ppm | +4.64…4.66 ppm | (reference) |
| Pi 5 | −53.9…−54.4 ppm | −53.70…−53.93 ppm | −0.44 ppm; −0.3 ppm |
The Pi 5's RP1 has its own 50 MHz crystal, which on our board runs 54 ppm slow; that is a static offset of that crystal (it moved by less than 0.5 ppm over 18 °C of CPU and 5 °C of RP1 warming). It does drift by about −0.5 ppm in the first minute of a transmission, as the RP1 warms up, which the estimate follows with its lag. The pilot follows the tone: 18998.966 Hz uncorrected on the Pi 5, 18999.994 Hz corrected. With systemd-timesyncd instead of chrony the Pi 5's estimate was 2.7 ppm off (−57.31 ppm), the error of timesyncd's frequency.
Observations are only used while the reference is synchronised: for the
system clock, while the kernel's NTP state says so, or the kernel's maximum
error is below 0.5 s (chrony keeps the kernel's unsynchronised flag set
unless rtcsync is configured, but every NTP daemon updates the error
bounds). With --ppm <value> the estimate still runs and is reported, but
the given value is applied; --ppm off neither measures nor corrects. The
PPM control command switches between the three at run time.
An SFN needs every transmitter to emit the same sample at the same instant (within a few microseconds) at the same carrier frequency. The current code already provides the pieces this rests on:
- Deterministic signal. Given the same programme audio, the same start sample index and the same parameters, the modulator produces bit-identical output: the pilot phase accumulator and the RDS bit clock are functions of the sample index alone, and RDS groups depend only on the emission time.
- Sample index ↔ time. Sinks report the hardware read position as an absolute sample index, and modulators receive the emission time of every block. The Pi 5 backend reads the exact DMA source address, so the position is known to one sample.
- Hardware timing without slips. Both backends pace the DMA from a crystal-derived hardware clock, with no software in the timing path, and the Pi 5's link power saving that caused one-sample slips is disabled.
The planned extension:
- Server: decodes the programme once and sends it to the transmitters as timestamped audio frames (or as a pre-rendered multiplex) together with RDS state, over the network, with enough delay (e.g. 500 ms) to cover jitter.
- Client time base: a GPS receiver's PPS signal on a GPIO pin is
timestamped (the kernel's
pps-gpiodriver, or a GPIO edge captured by DMA/PIO on the Pi 5). The simplest form already works today: chrony disciplines the system clock to the PPS, and theSystemClockreference measures the crystal against it. For the timeline alignment below, aPpsReferenceimplementsTimeReferencedirectly: it interpolates the sample index emitted at each PPS edge between the position readings around it, which gives observations on the GPS time scale with the interrupt latency as the only noise. - Start alignment: the client chooses the PPS edge at which sample
n0must be emitted and starts the DMA there (pre-filled buffer, tick started from the PPS-timestamped moment), then verifies the alignment by sampling the DMA position at subsequent PPS edges. - Rate discipline: the measured crystal error is applied as it is now,
to the carrier (
RfSink::set_clock_error_ppm; resolution < 0.1 Hz on the Pi 5) and to the sample timeline (RenderContext::sample_rate). An SFN additionally steers the timeline's phase: the estimator's line also gives the time offset of the sample clock, and a small extra term in the rate given to the modulator (or single samples inserted/dropped in a pre-rendered multiplex) pulls the emitted sample index onto GPS time. Carrier and timeline then get different corrections: the carrier the crystal error alone, the timeline the error plus the phase term. - Monitoring: each client reports its sample-index-at-PPS and carrier offset; the server checks the network is coherent.
None of this requires changes to the modulator or sink interfaces beyond what exists: the time mapping is already explicit, and the hardware paths are already free of software timing.