An FM broadcast transmitter for the Raspberry Pi, written in Rust. It turns a GPIO pin into a wide-band FM transmitter with a standards-conformant stereo multiplex (ITU-R BS.450) and RDS (EN 50067), on the Raspberry Pi 2, 3, 4, 5 and Zero 2 W, as 32-bit (armv7) or 64-bit (aarch64) static binaries. A Raspberry Pi Pico or Pico 2 with txengine's firmware does the same on USB, for any Linux computer.
Transmitting without a licence is illegal in most countries. The output is a square wave rich in harmonics. Connect it to a receiver with a shielded cable and an attenuator, or to a dummy load, and always use a low-pass filter before anything that can radiate.
- Hardware-timed output. Every sample is written to the carrier synthesiser by DMA, paced by a hardware timer: the CPU only has to keep a 60 ms buffer filled. Register writes are checked (lock, busy and status bits, read-back, frequency counter); nothing relies on sleeping.
- Modulation methods
- Pi 1-4 (BCM2835/6/7, BCM2711): fractional-N modulation of PLLA, PLLC or
PLLD (20-bit fraction, 1-3 Hz steps), or a dithered fractional clock
divider (
--pll clkdiv). The PLL is chosen automatically: on the Pi 2/3 and Zero 2 W PLLA, which only clocks the GPU's video and 3D blocks and may therefore move down by up to 15 %, which reaches the whole band; PLLs that clock the rest of the system (VPU, SD card, WiFi, UARTs) are only retuned within ±3 %. PLLA and PLLC run with a wider loop than the firmware gives them (loop gain KI 5 instead of 2), which removes most of their phase noise from the multiplex band. - Pi 5 (RP1): fractional-N modulation of the RP1 audio or video PLL (24-bit fraction, < 0.1 Hz steps).
- Pico and Pico 2 (RP2040, RP2350) on USB: their PLLs have integer
feedback dividers only, so the firmware switches
PLL_USB's divider between neighbouring values with a second-order sigma-delta modulator, 3 million times a second, by DMA paced by a PIO state machine that is aligned to the crystal. Alternatively (--synthesis pio) a PIO state machine puts the carrier out as a bit stream at 240-264 Mbit/s: less noise next to the carrier, but lines all over the FM band. txengine renders the samples on the computer.
- Pi 1-4 (BCM2835/6/7, BCM2711): fractional-N modulation of PLLA, PLLC or
PLLD (20-bit fraction, 1-3 Hz steps), or a dithered fractional clock
divider (
- Crystal error correction: the board's crystal error is measured
continuously against the system clock (NTP, or GPS through chrony) and
corrected in the carrier frequency, the subcarriers and the playback
speed; no calibration, and it follows temperature drift. A known error
can be given instead (
--ppm). - Multiplex (all derived from one phase accumulator, so pilot, 38 kHz, 57 kHz and the RDS bit clock are exactly locked): 50/75 µs pre-emphasis, 15 kHz programme low-pass, look-ahead limiter that bounds the true (inter-sample) composite peak, 19 kHz pilot, DSB-SC stereo subcarrier, RDS, and pre-compensation of the output stage's measured frequency response (sample-and-hold, interpolation and, on the Pi 2, the Zero 2 W and the Pico, the PLL loop).
- Levels: the drive is lowered by the rise of the multiplex power the pre-emphasis causes for pink noise (4.3 dB at 50 µs), so a programme keeps about the MPX power (ITU-R BS.412) it has without pre-emphasis; the status line meters it over the last 60 s. The limiter is pre-emphasis-aware: a high-frequency overload only reduces the pre-emphasis boost for a moment (less treble), and the whole programme is only turned down when it is over full scale without pre-emphasis. A programme at 0 dBr is normally transmitted unlimited.
- RDS: PS, RadioText (A/B flag, 0x0D termination), PTY, PTYN, PI, TP/TA, MS, DI, alternative frequencies (method A), clock time (placed at the minute edge using the actual emission time), ECC/LIC; EBU Latin character set (e.g. Czech diacritics); data-channel shaping exactly as specified.
- Audio input: WAV, FLAC, MP3, OGG/Vorbis, AAC/MP4 (symphonia), raw PCM on standard input, or a test tone; resampled with rubato; clock-drift compensation for live input. A WAV stream on standard input plays until it ends, whatever length its header declares.
- Run-time control through a FIFO or a Unix socket (PS, RT, PTY, TA, AF,
gain, ...), status reporting, clean shutdown and a guardian process that
restores every touched register even after
kill -9. - Testing tools: render to a WAV/IQ file, analyse a recorded multiplex (levels, pilot phase, RDS decode), transmit raw test tones, probe the frequency plan.
| Board | SoC | Status |
|---|---|---|
| Raspberry Pi 5 | BCM2712 + RP1 | tested over the air (64-bit build) |
| Raspberry Pi 2 v1.1 | BCM2836 | tested over the air |
| Raspberry Pi Zero 2 W | BCM2837 (RP3A0) | tested over the air (64-bit and 32-bit builds) |
| Raspberry Pi 2 v1.2, 3, 3+ | BCM2837 | supported, not yet tested on hardware |
| Raspberry Pi 4, 400 | BCM2711 | supported, not yet tested on hardware |
| Raspberry Pi 1, Zero, Zero W | BCM2835 (ARMv6) | not supported |
| Raspberry Pi Pico (on USB) | RP2040 | tested over the air |
| Raspberry Pi Pico 2 (on USB) | RP2350A | tested over the air |
The default output is GPIO4 (pin 7); on the Pico GP21 (pin 27, ground on
pin 28), the only clock output on its header. txengine devices lists the
transmitters found, and txengine probe -f 87.6 shows what will be used,
without transmitting.
Static binaries (musl) are cross-compiled with Rust's own linker; no C toolchain is needed:
rustup target add aarch64-unknown-linux-musl armv7-unknown-linux-musleabihf
cargo build --release --target aarch64-unknown-linux-musl # 64-bit OS
cargo build --release --target armv7-unknown-linux-musleabihf # 32-bit OSThe Raspberry Pi 5 needs the 64-bit build: the RP1's DMA engine reads the samples from main memory over PCIe without cache coherency, and only 64-bit user space can perform the cache maintenance this requires (the 32-bit build says so and stops). All other boards work with either build.
The binary is target/<target>/release/txengine. Copy it to the Pi; it needs
root to reach the hardware (/dev/mem, the VideoCore mailbox, or the RP1 PCIe resources).
On the Pi 2, 3, 4 and Zero 2 W, txengine programs the clock manager, DMA and
PWM registers directly. Kernels built with CONFIG_IO_STRICT_DEVMEM (NixOS,
for example; not Raspberry Pi OS) deny /dev/mem access to registers that a
kernel driver owns, even to root. On those kernels add iomem=relaxed to the
kernel command line (on NixOS: boot.kernelParams = [ "iomem=relaxed" ];).
txengine probe reports when it is missing.
txengine -V prints the version: git describe --tags --always --dirty of
the source tree it was built from, that is the latest tag, the number of
commits since it and the commit (before the first tag the commit alone),
with -dirty for uncommitted changes. Without -dirty, git accepts it as
a revision. Transmissions log it at start-up. A build without git (a
source archive, a sandboxed package build) can be given it in
TXENGINE_VERSION; otherwise it is the version in Cargo.toml.
The firmware (firmware/pico, a cargo workspace of its own) is built for
the Pico or the Pico 2 and loaded with
picotool:
rustup target add thumbv6m-none-eabi thumbv8m.main-none-eabihf
cd firmware/pico
cargo rp2040 && picotool load -x -t elf target/thumbv6m-none-eabi/release/txengine-pico # Pico
cargo rp2350 && picotool load -x -t elf target/thumbv8m.main-none-eabihf/release/txengine-pico # Pico 2The first time, hold the BOOTSEL button while connecting the Pico; after
that picotool reboot -u -f (with --ser SERIAL if several are connected)
puts it back into its bootloader, and a firmware fault does so by itself.
The firmware uses the Pico SDK's USB IDs (2e8a:000a and 2e8a:0009), to which
picotool's udev rules already give the logged-in user access: txengine needs
no root for a Pico.
The firmware's version has the same form as txengine's. txengine probe
shows it, and picotool info reads it from the board even in its
bootloader. A txengine refuses firmware that speaks another version of the
USB protocol and asks for an update.
Commands are grouped by transmission mode: txengine wfm tx transmits wide-band
FM, txengine wfm render renders it to a file and txengine wfm analyze
measures a multiplex recording. txengine probe and txengine tone need no
mode. Every command lists its options with --help.
# Stereo file with RDS on 87.6 MHz
sudo txengine wfm tx -f 87.6 -a music.flac --ps "MY RADIO" --rt "Now playing ..."
# From another program (WAV on stdin, or raw PCM with --raw-rate/--raw-channels)
ffmpeg -i stream.m3u8 -f wav - | sudo txengine wfm tx -f 87.6 -a -
arecord -f S16_LE -r 48000 -c 2 -t raw | sudo txengine wfm tx -f 87.6 -a - --raw-rate 48000 --raw-channels 2
# Carrier + RDS only, with clock time and alternative frequencies
sudo txengine wfm tx -f 87.6 --ps STATION --af 87.6 --af 99.5 --pty "Pop music"
# Change RDS while transmitting
sudo txengine wfm tx -f 87.6 -a music.mp3 --ctl /tmp/rds &
echo "RT Live from the studio" > /tmp/rds
echo "PS ONAIR" > /tmp/rds
# Through a Pico on USB, from any Linux computer
txengine devices
txengine wfm tx -f 87.6 -d pico:5054316550E3291C -a music.flac-d/--device picks the transmitter: pi (the Raspberry Pi txengine runs
on), pico (the only Pico connected), pico:SERIAL or pico:PORT (its USB
port, such as 3-1.2). Without it, txengine uses the only one it finds.
One command per line on --ctl (FIFO) or --ctl-socket (Unix socket, which
answers with OK ... or ERR ...). txengine removes the socket, and a FIFO
it created, when it stops:
| Command | Effect |
|---|---|
PS text, RT text, PTYN text|OFF |
programme service name, RadioText, PTY name |
PTY n|name, PI hex, DI hex, ECC hex, LIC hex |
codes |
TA 0|1, TP 0|1, MS 0|1, CT 0|1 |
flags |
AB |
toggle the RadioText A/B flag (receivers clear the display) |
AF 87.6,99.5 | OFF |
alternative frequencies |
RDS 0|1, RDSLEVEL hz |
RDS on/off, injection level |
GAIN db |
programme gain |
PPM, PPM auto|ppm|off |
report the crystal error correction; measure, fix or turn it off |
STATUS |
current settings and counters |
QUIT |
stop transmitting |
Every clock of the transmitter comes from the board's crystal, which is a
few ppm to a few tens of ppm off and drifts with temperature (10 ppm is
876 Hz at 87.6 MHz). That one error shifts the carrier, and it makes the
hardware emit the samples slightly too fast or too slow, which shifts the
pilot and the subcarriers and changes the playback speed. With the default
--ppm auto, txengine times the hardware's sample counter against the
system clock while transmitting, fits the crystal error (exponentially
weighted over about two minutes, so it follows a warming board) and
corrects both: the carrier synthesiser is retuned, and the multiplex is
rendered for the actual sample rate (pilot, subcarriers, RDS bit rate and
the programme's playback speed). The hardware sample clock itself is never
changed, so the correction cannot fill or drain any buffer; a live input
keeps following its own clock through its drift compensation.
The system clock has to be synchronised by an NTP daemon (chrony,
systemd-timesyncd, ...); until it is, txengine transmits uncorrected and
says so (txengine probe shows the state). The reference is the frequency
the daemon has measured for the system clock, so the result is as good as
that. With chrony (polling a LAN server every 16-64 s) the carrier, pilot
and playback speed of our three boards came within 0.3 ppm (26 Hz at
87.6 MHz); with systemd-timesyncd, which polls only every 34 minutes, the
Pi 5 was 2.7 ppm off. Use chrony. The first measurement is used after about 25 s. The
status line shows the correction in effect, and PPM on the control
interface reports the measurement in detail:
crystal error +6.73 ppm (measured against the system clock): correcting the carrier by -590 Hz and the sample rate
Without network time, give the error (--ppm 6.44, positive when the
crystal runs fast; txengine wfm tx and txengine tone print the measured value
on a board that has NTP); txengine keeps measuring and reports it, but
applies the given value. --ppm off turns the correction off (no
measurement either). For GPS accuracy, let chrony discipline the system
clock from a GPS receiver's PPS signal (gpsd + chrony's refclock); txengine
then measures against GPS time.
txengine wfm render -a music.flac --duration 10 -o mpx.wav # the multiplex as WAV
txengine wfm analyze mpx.wav # levels, pilot phase, RDSanalyze accepts any multiplex recording (for example from an SDR; see
tools/) and decodes RDS with an independent decoder.
Measured over the air with a USRP B210 (tools/sdr_capture.py,
tools/fm_analyze.py, txengine wfm analyze):
| Raspberry Pi 5 | Raspberry Pi 2 | Raspberry Pi Zero 2 W | |
|---|---|---|---|
| Sample rate | 250 kS/s | 190 kS/s (10 × 19 kHz within the DMA capacity, see below) | 190 kS/s |
| Pilot | 6.749 kHz (target 6.75) | 6.74-6.76 kHz | 6.75-6.76 kHz |
| 38 kHz carrier residual | 0.001 % | 0.005 % | 0.006 % |
| Stereo separation (left only) | > 50 dB (1 kHz) | 55–58 dB (100 Hz – 14 kHz) | 51–56 dB (1–14 kHz) |
| RDS injection | 2.02-2.06 kHz (target 2.0) | 2.03 kHz | 2.04 kHz |
| RDS block errors | 0 % | 0 % | 0 % (also at the end of a 10 min run) |
| Crystal error (carrier, pilot, playback speed) | −54 ppm; −0.3 ppm with --ppm auto |
+7.3 ppm; +0.1 ppm with --ppm auto |
+4.7 ppm; < 0.1 ppm with --ppm auto |
| Timing | no sample slips in 25 s tone tests | no sample slips | no sample slips |
| Modulation THD (10 kHz) | −71 dB | ||
| Audio SNR, mono / stereo (re 75 kHz) | ≥ 69.5 / ≥ 56.5 dB | 71.2 / 59.8 dB | 56.0 / 43.9 dB |
| Audio SNR, mono / stereo (re 0 dBFS) | ≥ 64 / ≥ 51 dB (65–70 / 53–56 dB at higher receiver gain) | 65.7 / 54.4 dB | 50.5 / 38.5 dB |
| Audio THD (1 kHz, 0 dBFS) | −91 dB | −72 dB | −69 dB |
| Audio frequency response (31.5 Hz – 15 kHz) | ±0.03 dB | ±0.04 dB | ±0.04 dB |
| CPU (stereo + RDS, one core) | 24 % at 600 MHz (throttled) | 11 % at 1 GHz, plus 4 % audio decoding |
Audio figures: a reference stereo decoder (pilot-locked, 50 µs de-emphasis), unweighted 20 Hz – 15 kHz. A 0 dBFS tone deviates 40 kHz, not 65 kHz, because of the −4.3 dB pre-emphasis drive correction. The noise comes from the carrier, not from the signal processing: an unmodulated carrier has the same floor. In stereo the noise from the 23–53 kHz subband adds to it. Measured per clock path (unmodulated carriers, AM and PM noise separated):
- Pi 2, Zero 2 W: phase noise of the PLLs (PM 8–16 dB above AM). PLLA, PLLC and PLLD measure the same on both boards, and neither the GPCLK divider, the pad drive nor the slew rate changes it; integer-N mode is 6 dB worse than fractional. With the firmware's loop gain it was −91 dBc/Hz (Pi 2) and −86 dBc/Hz (Zero 2 W) at 10 kHz; txengine's wider PLLA loop (see below) brings the Pi 2 to −101 dBc/Hz (stereo 44 → 54 dB). The Zero 2 W does not change with the loop gain: its noise has another source (it is worse under CPU load and has a −21 dBc 100 Hz spur, i.e. its supply). On the Pi 2 the mono SNR is now limited by noise that only appears with modulation (72 dB for an unmodulated carrier, 66 dB transmitting).
- Pi 5: about −104 dBc/Hz at 10 kHz, with equal AM noise: noise
added after the PLL, not phase noise of it. It is the same for every
pll_audiosetting (VCO 1050–1750 MHz, integer or fractional, dividers), forpll_sys, and for any pad drive or slew. At a B210 gain of 20 dB the receiver contributes about half of it, so the table gives lower bounds.
The multiplex as computed (txengine wfm render) has a noise floor of
−128 dBFS (21 bits); quantising it to the frequency steps of the hardware
gives −123 dBFS on the Pi 5 (0.17 Hz steps) and −106 dBFS on the BCM
boards (1.41 Hz steps). Both are far below the RF noise.
Hardware-specific findings that txengine handles:
- Pi 2 and Zero 2 W (and probably other BCM283x boards): a DMA write to
a PLL's fractional register takes about 4.3 µs, which caps the sample rate
at about 231 kS/s. txengine measures the capacity at start-up and picks the
highest even multiple of 19 kHz within 85 % of it, 190 kS/s
(
--sample-rateabove the capacity is refused), and it watches the achieved rate while running. - Pi 2 and Zero 2 W, sample rate and stereo decoders: the output holds
each sample, so the multiplex is repeated around multiples of the sample
rate, attenuated only by the hold: the pilot's images at
fs ± 19 kHzare 19 dB below it. An analogue stereo decoder switches with a 38 kHz square wave, whose 3rd, 5th and 7th harmonics (114, 190, 266 kHz) demodulate whatever the receiver's IF passes there. At the former rates (193–196 kS/s, chosen with 1 kHz steps) the pilot's image at 174–177 kHz beat with 190 kHz into a tone: for the Zero 2 W at 195.9 kS/s (176.9 kHz against 190 kHz) a model of such a decoder, fed with the received signal, gives 13.1 kHz at −71 dB re 75 kHz (−82 dB behind a ±150 kHz IF). At an even multiple of 19 kHz every such product falls on 19 kHz or onto the programme's own frequencies, so 190 kS/s it is: the images then lie exactly on the 9th and 11th harmonic of the received pilot, and the model's tone is gone (−96 dB on the Zero 2 W, its noise floor). From PLLD_PER (500 MHz) the nearest integer divider gives 189.97 kS/s, so the pacer uses a fractional (MASH 1) one: it moves each sample by at most 2 ns, and interleaved runs on a Pi 2 measured the same noise (within 0.3 dB) as with the integer divider. The Pi 5 (250 kS/s; its pacer divides 50 MHz by an integer, which gives no multiple of 19 kHz near it) has its images at 231 and 269 kHz: only the 7th harmonic reaches one (3 kHz, −67 dB in the model with an unlimited IF), and a ±150 kHz IF removes it (−90 dB). - Pi 2 and Zero 2 W, PLLA's and PLLC's loop: with the firmware's loop gain (KI 2) the loop bandwidth is about 125 kHz. That leaves the VCO's phase noise around 10–30 kHz offset, in the audio and stereo bands, poorly suppressed (Pi 2: 44 dB stereo SNR), and makes the modulation response rise by 0.57 dB with 6° of lag at 67 kHz (35 dB separation uncompensated). txengine raises the gain to KI 5 while modulating (about 600 kHz bandwidth): on the Pi 2 the stereo SNR rises to 54 dB, the response is flat within 0.12 dB (the rest is measured and compensated) and the separation is 55 dB from 100 Hz to 14 kHz (Zero 2 W: 51–53 dB at 5–14 kHz instead of 37–44 dB; its noise does not change). Costs: the wider loop's noise peak at 300–600 kHz offset (+1 dB in the neighbouring 200 kHz channels), and the 3rd harmonic of a full-scale 1 kHz tone at −80 instead of −86 dB. The firmware's gain is restored afterwards. 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 (Pi 2, PLLC: stereo SNR 53.2 dB and separation 58–60 dB at KI 5, 44.6 dB and 33 dB at KI 2 uncompensated). The response is not perfectly stable: between two sessions it moved by 0.15 dB at 67 kHz on both boards, and by 0.05 dB under CPU and network load, which leaves the separation unchanged.
- Pi 2 and Zero 2 W, choosing the PLL: retuning a PLL moves every clock derived from it. PLLC clocks the VPU, from which the SD card host, SPI, I2C and the mini UART (the serial console on boards with WiFi) take their clocks, and the WiFi's SDIO host. The SD and SDIO buses run at their 50 MHz maximum and a UART tolerates about ±3 % of baud rate error, so PLLC (like PLLD: PL011 UART, HDMI) stays within ±3 %, which reaches only half of the band. PLLA only clocks the GPU's video and 3D blocks, idle on a transmitter, so txengine prefers it and lets it move down by up to 15 % (the blocks run slower; upwards the 3 % limit stays). That reaches every carrier in 87.5–108 MHz on a 10 kHz raster (from the firmware's 2250 MHz at most −12 %), and the response, noise and separation measure the same at −14 % (VCO 1927 MHz) as at +1.2 %. PLLC moved by −3.6 % caused no SD or WiFi errors and no lost packets on either board.
- BCM283x/BCM2711, CPU clock changes: the firmware changes the ARM clock
over the same slow PLL register bus that the DMA engine keeps busy. When
the two collide, the firmware's write lands in the modulated PLL instead:
on the Zero 2 W the carrier vanished a few seconds into a transmission.
txengine holds the CPU clock at its maximum (cpufreq governor
performance) while it modulates a PLL and restores the previous governor afterwards, even afterkill -9. Should the PLL still be changed (e.g. by a thermal throttle), it is repaired, typically within 20-40 ms (a 12-36 ms dropout in tests that forced collisions or changed its dividers). It is watched through its lock bit and the carrier frequency (the clock manager's frequency counter), not by reading its registers: every read on that bus delays the DMA engine's next write by about 3 µs, which is an audible click. - Zero 2 W (Cortex-A53): a floating-point register load from a peripheral register at an address ≡ 4 (mod 8) returns the register below it. All register accesses are explicit 32-bit integer loads and stores.
- Pi 5: PCIe link power saving (ASPM L1) occasionally delays the DMA enough to lose a timer tick, shifting the output timeline by one sample. txengine disables ASPM on the RP1 link while transmitting and restores it afterwards.
- A weak power supply throttles the CPU; txengine reports the firmware's
under-voltage and throttling flags at start-up (
txengine probeshows them too).
Measured through a coaxial cable with the USRP B210 at 87.6 MHz, 12 mA drive. PLL method: 6 MHz reference (crystal / 2), 3 MHz updates, 166.7 kS/s (18 updates per sample). PIO method: 249.6 Mbit/s, 190.2 kS/s (41 words per sample). Both interpolate linearly between samples.
| Pico, PLL | Pico, PIO | Pico 2, PLL | Pico 2, PIO | |
|---|---|---|---|---|
| Pilot | 9.0 % | 9.0 % | 9.0 % | 9.0 % |
| 38 kHz carrier residual | 0.004 % | 0.008 % | 0.018 % | 0.021 % |
| Stereo separation (left only, 1 kHz) | 72–89 dB | 72–73 dB | 62–66 dB | 61–73 dB |
| RDS block errors (stereo music) | 0 % | 0 % | 0 % | 0 % |
| FM noise of an unmodulated carrier, audio / stereo band (rms re 75 kHz peak, unweighted, no de-emphasis) | −69 / −49 dB | −72 / −55 dB | −59 / −48 dB | −71 / −53 dB |
| Phase noise at 100 / 300 / 600 kHz / 1 MHz (dBc/Hz) | −91 / −78 / −77 / −99 | −95 / −86 / −95 / −104 | −90 / −84 / −78 / −99 | −93 / −90 / −94 / −99 |
| Strongest line more than 1 MHz from the carrier (unmodulated, 82–112 MHz) | −41 dBc (±1.2 MHz) | −18 dBc (97.2 MHz) | −40 dBc (±1.2 MHz) | −15 dBc (97.2 MHz) |
The crystal is corrected as on a Raspberry Pi (--ppm auto; the Pico's is
+11 ppm, and with the correction the carrier stays within 10 Hz).
PLL method. What the RP2's PLLs (VCO 750–1600 MHz, integer feedback divider) require:
- A divider value must stay for at least four crystal cycles. With a new one every two (6 MHz) or three (4 MHz) cycles the carrier dissolves into noise at any write timing, on both chips; every four (3 MHz) it is clean. The value apparently crosses into the PLL's reference clock domain through a synchroniser that needs that long.
- Writes must avoid a narrow window of the reference cycle, about three system clock cycles (21 ns at 144 MHz) wide: a write there smears the carrier, its neighbours degrade it. The firmware therefore synchronises its PIO state machine to a crystal edge once at start (the crystal clock is put on GPIO 25, the LED, for that moment) and writes in the middle of the good range. The phase was measured on both chips: 1–4 cycles after the edge is bad, 5–11 and 0 are clean.
- A lower comparison frequency is quieter. With the crystal divided by two (6 MHz) a divider step moves the carrier half as far as with 12 MHz, and the modulator's noise next to the carrier drops by 7–12 dB; every FM channel can be planned with it.
- The PLL's loop is wide: it hardly filters the modulator's noise, which spreads up to ±1.5 MHz around the carrier and peaks near 300 kHz, where the loop amplifies it. On an exact raster frequency it also forms tones, e.g. at multiples of 600 kHz at 87.6 MHz (about −32 dBc on an unmodulated carrier; with modulation they become noise). Emissions into neighbouring channels are much higher than from a Raspberry Pi. A one-bit second-order modulator, which moves the divider by one step at most, and a MASH 1-1-1 were both worse.
- The loop lifts the multiplex band slightly, as f²: +0.12 dB at 38 kHz on the RP2040, +0.15 dB on the RP2350, half that with a 12 MHz reference, the same over the band. Uncorrected that limits the separation to 41–44 dB; txengine pre-compensates it.
- RP2350: the DMA may not access
PLL_USBuntil ACCESSCTRL allows it; the firmware does.
PIO method (--synthesis pio). The system clock runs from PLL_USB at
the bit rate, 240–264 MHz, while it transmits, at 1.20 V core voltage
(both are restored afterwards); USB stays on PLL_SYS. Each bit is the
square wave's sign at that instant, so the carrier has no dithering noise
and the PLL's loop is out of the modulation path (the response is exact).
But a square wave whose edges are rounded to 4 ns bits contains its
harmonics folded back by multiples of the bit rate, and some land in the
FM band: txengine chooses, per carrier, the bit rate whose strongest such
alias is weakest (about −25 dBc at worst for an ideal bit stream). The
pin's output measures 6–17 dB stronger on many of these lines, up to about
−15 dBc, on both chips, at 4 and 12 mA and at 240 as well as 249.6 Mbit/s;
the cause is not known yet. The method is therefore only for a dummy load
or a cable to a receiver. On the RP2350 the double-rate HSTX output could
make the same stream without the higher clock and voltage (not done).
Safety. If txengine dies, the firmware switches the carrier off after
one second without samples. A watchdog (one second) reboots a hung
firmware, which also stops the carrier; txengine probe then shows where
it had stopped. Panics and hard faults reboot into the USB bootloader.
Over USB a status request takes 0.1–0.15 ms.
- docs/ARCHITECTURE.md: crates, signal flow, the hardware backends, how to add transmission modes and hardware, and the design for GPS-synchronised single frequency networks.
GPL-3.0-or-later.