Transmit a complete FM broadcast between two COSMOS nodes — synthesised music, a real stereo multiplex, and RDS carrying a station name — then receive it and listen live in your browser while the name appears on screen.
You build both ends. That is the point: decoding somebody else's broadcast teaches receiver DSP, but building the transmitter is what explains why the signal looks the way it does — why the pilot is at 19 kHz, why RDS needs no carrier recovery, and why a 1960s stereo scheme had to be invisible to every radio already in the field.
Then, in Part 5, you point the same decoder at a real broadcast station on a $25 dongle — and watch it recover a callsign you can check against the FCC's own arithmetic. The station you built is the one you control; the one on air is the one that tells you whether your receiver really works.
An FM broadcast is not one signal. It is four, stacked in frequency inside a single multiplex (MPX) that then frequency-modulates the carrier:
0 - 15 kHz L+R mono audio - all a mono radio ever hears
19 kHz pilot tone the key to everything above it
23 - 53 kHz (L-R) on 38 kHz stereo difference, suppressed carrier
57 kHz RDS 1187.5 bit/s data, suppressed carrier
Everything above 15 kHz is inaudible to a mono receiver, which is the entire design constraint: when stereo was added in 1961, and RDS in the 1980s, neither could break a single radio already in service. A mono set low-passes at 15 kHz and never knows the rest is there.
The pilot is the trick that makes the other two possible. A suppressed carrier saves power but leaves the receiver with nothing to lock onto — and worse, no way to know its phase. So the transmitter sends one small tone at 19 kHz, and places the other subcarriers at exactly twice and three times it. Square the pilot and you have the 38 kHz reference; cube it and you have 57 kHz. One tone, both references, phase-locked for free.
By the end you will be able to:
| What you build | A stereo FM broadcast with RDS, transmitted and received on COSMOS |
| How you run it | Interactive. Start a transmitter, then listen from your own machine |
| Estimated time | 30 min to run it; 2 h to work through the DSP |
| Domain / sandbox | Parts 1–4: sb6 — two X310s, cabled through a fixed 60 dB attenuator. Part 5: grid — one node with an RTL-SDR |
| Band | 100.1 MHz, inside coax. Nothing radiates. Part 5 receives only, off air. |
| Client needs | A browser and ssh. Nothing to install. |
| Topic group | SDR & GNU Radio |
| Last verified | Parts 1–4: 2026-08-05 (sb6). Part 5: 2026-09-22 (grid node20-18) |
You do not need to image the nodes first — the setup command below does it if they are not already on baseline-sdr.ndz.
No DSP background is assumed. Every piece of maths below is worked through.
Sandbox sb6 — cabled, two X310s
| Resource | Role | Notes |
|---|---|---|
node1-1.sb6 |
transmitter | USRP X310, TX/RX port |
node1-2.sb6 |
receiver | USRP X310, RX2 port |
| fixed attenuator | the path | 60 dB, coax — the signal never leaves the cable |
Disk image: baseline-sdr.ndz on both.
The sb6 radios are not on the control plane. Each X310 is cabled point-to-point to its own host's SFP+ port, and that port comes up with no address — so
uhd_find_devicesreports nothing at all on a perfectly healthy node. Both radios answer on the same192.168.40.2, each alone on its own run.The setup step below brings this up for you. It is shown here because it is the single most confusing thing about
sb6: absent hardware and an unaddressed port look identical. To check it by hand:
shell ip addr add 192.168.40.1/24 dev enp1s0 && ip link set enp1s0 up mtu 9000 uhd_find_devices --args="addr=192.168.40.2"
For Part 5 only — receiving a real station, on grid
| Resource | Role | Notes |
|---|---|---|
node20-18.grid |
receiver | RTL-SDR dongle (R820T) with a VHF whip at the window |
baseline-sdr.ndz |
image | The experiment installs rtl-sdr itself and blacklists the kernel's DVB-T driver |
Part 5 needs no transmitter, no reservation on sb6, and no USRP. It is receive-only: a
broadcast station is already on the air.
node1-1.sb6 node1-2.sb6
┌──────────┐ ┌──────────┐
│ X310 │ TX/RX ──[ fixed 60 dB, coax ]── RX2│ X310 │
└──────────┘ └──────────┘
│ 100.1 MHz, FM, ±75 kHz deviation │
│ │
music + RDS ────────────── you ──────────────► browser
One command, run once on console.sb6.cosmos-lab.org. It images both nodes if they are not already carrying baseline-sdr.ndz, brings up each node's private 10 GbE run to its X310, puts the DSP in /root/fm, and installs the two listener wrappers on PATH:
cosmos-run sdr-gnuradio/fm-broadcast.description.yml
TASK [fm_broadcast : The station's DSP imports on this node] ***
ok: [node1-1.sb6.cosmos-lab.org]
ok: [node1-2.sb6.cosmos-lab.org]
TASK [fm_broadcast : Report what is staged] ***
"FM station staged on node1-1.sb6.cosmos-lab.org: DSP in /root/fm,
`listen` and `listen-http` on PATH, tuned to 100.1 MHz."
That is the only automated step, and deliberately so. Everything after it you run by hand: start the station, listen to it, change what it says, watch the decoder follow. That is the exercise, and automating it would remove what this page teaches. What was never your job is copying ten Python files onto two nodes — so that, and only that, is what the command does.
Parameters — override any of these with -e '{"cosmos_parameters": {"freq_hz": 99500000}}':
| Parameter | Default | What it sets |
|---|---|---|
freq_hz |
100100000 |
carrier, and the frequency baked into listen / listen-http |
tx_gain |
25 |
transmit gain |
rx_gain |
30 |
receive gain |
http_port |
8000 |
port listen-http serves on |
There is no sound file. The music is generated by music.py — a sixteen-bar loop in A minor with drums, bass, an electric piano, a pad and a lead line — because a shipped audio file carries a licence and can go missing, and a plain tone would teach nothing about stereo.
Two properties of it are load-bearing, not decoration:
It loops seamlessly. The transmitter repeats the buffer forever, so notes are placed with wrap-around: a chord struck in the last bar keeps ringing at the beginning, which is exactly where it lands on the repeat. A tail that simply stops puts a click in every loop.
It is genuinely stereo — kick and bass centred, piano left, pad wide, lead right, measuring −10.5 dB side-to-mid. That is what makes decoding the 38 kHz difference channel audible: a receiver that ignores it collapses the mix to mono and sounds obviously duller.
With left and right audio in hand, the multiplex is four terms added together:
mono = (l + r) / 2.0
side = (l - r) / 2.0
mpx = (0.45 * mono
+ 0.09 * np.sin(2 * np.pi * 19000 * t) # pilot
+ 0.45 * side * np.sin(2 * np.pi * 38000 * t) # stereo, DSB-SC
+ 0.05 * rds_waveform * np.cos(2 * np.pi * 57000 * t))
Why mid and side, rather than sending L and R? Because mono compatibility requires the baseband to be L+R. A mono receiver takes the bottom 15 kHz and gets a correct mono mix for free. Stereo is then additional information: given M = (L+R)/2 and S = (L−R)/2, a stereo receiver recovers L = M+S and R = M−S. Nothing is duplicated.
Why is the stereo subcarrier suppressed? side × sin(2π·38000·t) is double-sideband suppressed carrier — the 38 kHz tone itself is not transmitted, only the sidebands around it. Transmitting the carrier would waste power on something carrying no information. The cost is that the receiver must reconstruct its phase, which is what the pilot is for.
Why is the pilot only 9%? It is pure overhead — a tone carrying no audio. It must be big enough to recover reliably and small enough not to steal deviation from the programme. Around 8–10% is the standard compromise.
Measured on the multiplex this tutorial transmits, relative to the noise shoulder between 21 and 30 kHz:
| component | level |
|---|---|
| audio, below 15 kHz | 47.0 dB |
| guard band, 15–18 kHz | 18.7 dB |
| pilot, 19 kHz | 70.4 dB |
| stereo, 38 kHz | 51.4 dB |
| RDS, 57 kHz | 49.5 dB |
The guard band matters more than it looks — see the trap at the end of Part 4.
phase = 2 * np.pi * deviation * np.cumsum(mpx) / rate
iq = np.exp(1j * phase)
FM puts the information in the derivative of the phase. Integrating the multiplex gives phase; the exponential turns phase into a complex baseband signal of constant magnitude. That constant magnitude is why FM resists amplitude noise, and why — unlike the OFDM elsewhere in these tutorials — there is no peak-to-average problem and no drive window to probe.
mpx = np.angle(iq[1:] * np.conj(iq[:-1]))
That is the whole demodulator, and it is worth understanding rather than copying. Multiplying each sample by the conjugate of the previous one gives a complex number whose angle is the phase difference between them. Phase difference per sample is frequency. Since FM encoded the multiplex as frequency, the angle is the multiplex.
No local oscillator, no carrier recovery, no phase-locked loop. This is why FM demodulation is cheap enough to have been done with a handful of components for seventy years.
pilot = bandpass(mpx, 18500, 19500)
p = analytic(pilot) # Hilbert transform
p /= np.abs(p) # unit magnitude: phase only
The analytic signal turns a real tone into a complex one rotating at the same rate — sin(θ) becomes −j·e^{jθ}. Normalising to unit magnitude leaves pure phase. Now the arithmetic that the whole scheme was designed around:
side_ref = -2.0 * np.imag(p ** 2) # 38 kHz, phase-locked
rds_ref = p ** 3 # 57 kHz, phase-locked
Squaring a complex exponential doubles its angle; cubing triples it. The subcarriers were placed at 2× and 3× the pilot precisely so that this works. There is no carrier recovery loop anywhere in this receiver.
The sign is not a detail.
analytic(sin θ) = −j·e^{jθ}, sop²is−e^{j2θ}andimag(p²)is−sin(2θ). The minus sign inside_refrestores the transmitted phase. Get it wrong and the stereo difference inverts, which silently swaps left and right — every summary statistic, including stereo separation, looks perfect.
mono = lowpass(mpx, 15000)
side = lowpass(mpx * side_ref, 15000)
left, right = mono + side, mono - side
Multiplying by the reference shifts the 38 kHz sidebands back to baseband; the low-pass discards the images. The factor 2 in side_ref undoes the ½ that falls out of multiplying two sinusoids.
RDS is the most intricate part and the most satisfying to decode, because the payoff is a name appearing on your screen.
1187.5 bit/s, which is 57000 ÷ 48 — chosen so the bit clock divides the subcarrier exactly. At the 228 kHz multiplex rate that is precisely 192 samples per bit, which is why the sample rates in this tutorial are what they are.
Two codings are applied, each solving a specific problem:
Differential encoding — transmit bit = previous transmit bit XOR data bit. A suppressed-carrier BPSK receiver cannot tell a constellation from the same one rotated 180°; there is nothing to compare against. Differential coding makes the data depend on changes, so an inverted stream decodes identically. You will see this in the receiver: no polarity search is needed anywhere.
Biphase (Manchester) symbols — each bit becomes a transition, 1 → +− and 0 → −+. This removes DC and, more importantly, removes energy at the subcarrier frequency, so RDS does not interfere with the pilot's third harmonic that it is riding on.
group = 4 blocks 104 bits, 87.6 ms
block = 16 information + 10 check 26 bits
check = CRC-10 XOR an offset word
The CRC generator is x¹⁰ + x⁸ + x⁷ + x⁵ + x⁴ + x³ + 1. Ordinary enough. The offset word is the elegant part. It differs per block position:
| position | offset |
|---|---|
| A | 0x0FC |
| B | 0x198 |
| C | 0x168 |
| D | 0x1B4 |
A receiver that has just tuned in has a stream of bits and no idea where a block starts. It takes any 26 bits, recomputes the checkword from the top 16, and XORs the received checkword:
syndrome = (word & 0x3FF) ^ crc10(word >> 10)
If the result equals one of the four offsets, this is a valid block and you now know its position in the group. Block synchronisation and error detection are the same operation. No preamble, no sync word, not one wasted bit.
Block A is always the programme identification code. Block B's top bits give the group type, which says what C and D mean:
That is why the name appears in fragments and fills in — you are watching a carousel arrive.
z = lowpass(mpx * np.conj(p ** 3)) # complex, both quadratures kept
theta = 0.5 * np.angle(np.mean(z ** 2))
base = np.real(z * np.exp(-1j * theta))
Why complex, and why the residual angle? Cubing the pilot triples any phase the pilot's own filter left behind, so a few degrees becomes tens. At 90° a real reference multiplies the data by something in quadrature with it and the product averages to exactly zero — the data does not degrade, it vanishes. During development the measured correlation between a real reference and the subcarrier came out at
-0.000. Keeping both quadratures lets the residual angle be measured and removed: BPSK lies on a line through the origin, and squaring turns a line into a single direction, so the residual is half the angle of the mean square.
Then a matched filter for the biphase symbol, a search over the 192 possible sampling phases, differential decoding, and the block-position search of §3.2.
On console.sb6.cosmos-lab.org:
ssh root@node1-1 systemd-run --unit=cosmos-fm-tx --collect \
--working-directory=/root/fm /usr/bin/python3 /root/fm/fm_tx.py \
--freq 100.1e6 --gain 25 --dev-args=type=x300,addr=192.168.40.2 --antenna TX/RX
It loops until stopped.
One line, nothing to install — works in PowerShell, cmd, or any POSIX shell:
ssh -L 8000:localhost:8000 -J you@console.sb6.cosmos-lab.org root@node1-2 listen-http
Leave it running and open http://localhost:8000. If you have ffmpeg or sox, this pipes instead:
ssh -J you@console.sb6.cosmos-lab.org root@node1-2 listen | ffplay -nodisp -
You should see the name assemble as the carousel arrives:
tuned 100.1000 MHz, gain 30 - audio on stdout, 1 s blocks
RDS PI 0x4352 station '' text 'COSM estbed - synthetic stereo FM wit'
RDS PI 0x4352 station 'COSMOSFM' text 'COSMOS testbed - synthetic stereo FM wit S'
RDS PI 0x4352 station 'COSMOSFM' text 'COSMOS testbed - synthetic stereo FM with RDS'
Listen in headphones. The parts are deliberately spread across the image — that separation is the 38 kHz subcarrier doing its job, and it is what a mono decoder would throw away.
--ps "MY-RADIO" --radiotext "Anything up to sixty-four characters"
Everything so far decoded a signal you generated. Now decode one you did not — the same
fmstereo and rds code, a real transmitter, and a dongle that costs less than lunch.
cosmos-run sdr-gnuradio/fm-offair.description.yml
That stages the receiver on the grid node, installs rtl-sdr, keeps the kernel's DVB-T driver
off the dongle, captures twelve seconds and checks what came back. To listen instead, use the
same one-liner as Part 4 — only the console and node change:
ssh -L 8000:localhost:8000 -J you@console.grid.cosmos-lab.org root@node20-18 listen-http
Open http://localhost:8000. The page names whatever station is tuned.
RF -9.5 dBFS pilot -26.0 dB stereo -6.0 dB
blocks 548/548 groups 100
PI 0x8374 STATION 'WRSU-FM' RADIOTEXT 'WRSU-FM Rutgers 88.7 732-932-8800 GO RU ! wrsu.org'
Check the PI yourself. For a US station the call sign maps to the programme identifier by a
published formula — 21672 + 676·c₂ + 26·c₃ + c₄ for a W call, with
A = 0:
W R S U -> 21672 + 676·17 + 26·18 + 20 = 33652 = 0x8374
That is the number on screen. It arrived through a CRC-10 checkword, from a transmitter nobody
in this experiment controls — which is a far stronger statement than any signal-strength reading.
A zero-IF receiver must not be tuned at the carrier. These dongles put whatever they are
tuned to at DC, on top of their own LO leakage and DC offset. FM demodulation is a phase
measurement (§2.1), so a constant vector added to every sample biases every phase difference.
Tuned straight at 88.7 MHz this receiver decoded nothing — no pilot, no RDS — on a signal
arriving at −9 dBFS, and it still produced audio that sounded like something. Tuned 150 kHz low
and mixed down in software, the same dongle and antenna decoded the RDS above immediately. The
station you built in Part 1 never showed you this, because a USRP is not zero-IF in the same way.
A plausible metric can be exactly backwards. The obvious pass/fail test is "the 19 kHz pilot
must be strong". It does not work here: pilot_db is the pilot relative to the whole
multiplex, so programme audio pushes it down. Measured on this node:
| Channel | pilot_db |
|---|---|
| WRSU 88.7, receiving perfectly | −26.0 dB |
| 93.5 — empty | −21.9 dB |
| 96.1 — empty | −20.6 dB |
| 107.9 — empty | −21.2 dB |
Three empty channels beat the working station. A threshold on that number would have passed
noise and failed reception. The experiment therefore gates on a decoded RDS PI plus a level
floor, and reports pilot_db only as a diagnostic. Ask of any metric: what does noise score?
--require-rds and judge those by the audio.Parts 1 to 5 build a station and receive one. This part opens the receiver. It runs on a recorded 6 seconds of WRSU 88.7 that ships with the tutorial, so it works whether or not you have a radio in front of you, and the answer is known in advance.
python3 /run/cosmos/fm/fm_dsp.py --iq /run/cosmos/fm/wrsu_88.7_6s.cs8 --station "WRSU 88.7"
loaded 6.0 s at 456000 Hz, RF -8.0 dBFS
1. demodulate phase derivative -> 1368000-sample multiplex at 228000 Hz
2. multiplex 19 kHz pilot +60.4 dB | 38 kHz stereo +25.0 dB | 57 kHz RDS +10.3 dB
3. pilot recovered; doubling gives 38 kHz, tripling gives 57 kHz
so NO carrier recovery loop is needed for either
4. stereo L = (L+R)+(L-R), R = (L+R)-(L-R) [fmstereo.decode_stereo]
5. RDS 57 kHz, 1187.5 bps, derived from 3x the pilot
blocks 273/273, groups 50, PI 0x8374, PS 'WRSU-FM'

FM's information is in the instantaneous frequency, so the first step is a phase derivative — the angle between consecutive IQ samples. What comes out is not audio. It is a stack of signals called the multiplex, and panel 2 shows it:
| Where | What | Why it is there |
|---|---|---|
| 0 – 15 kHz | L+R, the mono sum | a mono radio uses only this — which is how stereo was added in 1961 without breaking every existing receiver |
| 19 kHz | the pilot | a pure tone carrying no information at all |
| 23 – 53 kHz | L−R, the stereo difference | double sideband suppressed carrier, centred on 38 kHz |
| 57 kHz | RDS | 1187.5 bits per second |
Then L = (L+R) + (L−R) and R = (L+R) − (L−R).
Look at where those subcarriers sit: 38 kHz is exactly twice the pilot, and 57 kHz is exactly three times it. That is not a coincidence, it is the design. The pilot is transmitted for no purpose except to be a phase reference:
So a receiver never has to recover either carrier — it derives them from a tone that is already present and already phase-locked. That is why the decoder in this tutorial contains no PLL, no Costas loop, and no carrier tracking of any kind. Compare that with the two other receive tutorials here, which put their information somewhere else entirely:
| Tutorial | Information lives in | What must be recovered |
|---|---|---|
| FM (this page) | instantaneous frequency | nothing — the pilot supplies the references |
| ACARS / VDL Mode 2 | phase change between symbols | symbol timing only |
| ADS-B | amplitude alone | pulse position; the phase is discarded |
Same dongle, same kind of IQ stream, three different places to look. If you take one thing from the SDR tutorials on this testbed, take that.
1. Tuning at the carrier. An RTL-SDR and a USRP are both zero-IF, so their DC offset and LO leakage land exactly on the carrier — and since FM demodulation is a phase measurement, a constant vector added to every sample biases every phase difference.
How bad this is depends on the radio, and it is worth being precise rather than repeating a rule. Measured on grid's RTL-SDR, WRSU 88.7, 12 s captures, offset-tuned against tuned-on-carrier:
| gain | offset-tuned | on-carrier |
|---|---|---|
| 12.5 | 547/547 blocks | 547/547 — no penalty at all |
| 28.0 | 547/547 | 544/547 |
| 40.2 | 547/547 | 534/547 |
| 49.6 | 547/547 | 529/547 |
On that dongle it costs a slow trickle of errors that grows with gain. On a USRP the same mistake was near-fatal: a station at −33.7 dBFS with a healthy 46 dB pilot decoded 9 of 913 blocks on-carrier and 830 of 913 offset-tuned.
So offset tuning is not a law, it is free insurance against a penalty that ranges from nothing to total on hardware you may not control. And the important part: you cannot see this in a spectrum. On the very capture that decoded worse, the on-carrier 57 kHz subcarrier measured stronger (+13.3 dB against +10.4). Only attempting a decode reveals it.
2. A sample rate that is nearly right. RDS runs at 1187.5 bps and the decoder works at exactly 456000 Hz. Ask a USRP for that and check what you actually get:
| Radio | Master clock | Asked | Delivered | Error |
|---|---|---|---|---|
| RTL-SDR | — | 912000 (4 × the multiplex rate) | exact by construction | 0 |
| USRP N310 | 125 MHz | 456000 | 456204.4 | +204 Hz (448 ppm) |
| USRP X310 | 200 MHz | 456000 | 456621.0 | +621 Hz (1362 ppm) |
Neither USRP can produce it, because the decimation would have to be 274.12 and 438.60 respectively, and decimation is an integer. A +204 Hz error slips 10.6 RDS symbols in 20 seconds and the decoder never syncs. The RTL-SDR escapes only because 912000 is exactly four times 228000 by design.
The fix is to ask for a rate the clock can produce — 1 MS/s and 5 MS/s are exact on both USRPs — and resample. Always read back get_rx_rate(); UHD does not warn you.
3. Capture length. The decoder picks one sampling phase for the whole capture and has no tracking loop, so a sub-ppm difference between the radio's clock and the transmitter's eventually walks off the symbol:
| capture | fixed phase | decoded in 10 s windows |
|---|---|---|
| 12 s | 547/547 blocks, 136 groups | same |
| 30 s | 838/1369, 0 groups | 1367/1367, 340 |
| 60 s | 856/2739, 0 | 2734/2734, 679 |
| 120 s | 1193/5480, 0 | 5461/5470, 1238 |
An RDS group is four blocks and 88 ms, entirely self-contained, so short windows can simply be decoded separately and merged. decode_rds now does this automatically for anything over 15 seconds — which is why the shipped 12 s default never showed the problem.
Two cleverer fixes were tried first and both were worse. A Gardner timing loop chased noise, reporting ±30 ppm against a true offset well under 1 ppm and decoding worse than the fixed phase on short captures. Estimating the period from the phase difference between the two halves of a capture gave +17.5, +17.0, −3.7 and +2.9 ppm on four runs of the same station — which for two crystal oscillators cannot all be true. The dull answer won.
Note on the sample. The shipped recording is from grid's RTL-SDR. The two USRP rows above were measured on a BED N310 and a grid X310, and the USRP-sourced sample is still to come — see the tutorial's changelog.
| Check | Expected |
|---|---|
| RDS blocks valid | all of them — 569/569 in loopback |
| Station name | COSMOSFM, stable once confirmed |
| Stereo separation | about −9.7 dB side/mid, against −10.5 transmitted |
| Audio continuity | 99.3 % of real time |
Part 5, off air on grid:
| Check | Expected |
|---|---|
| Programme identifier | 0x8374 — and it equals the call sign arithmetic above |
| RDS blocks valid | 548/548 on a strong local station |
| Channel level | about −9 dBFS. An empty channel reads −40 or lower, and the run fails |
| An empty frequency | fails, exit 1, naming both gates — a WAV of noise is not a reception |
ssh root@node1-1 systemctl stop cosmos-fm-tx
| Symptom | Cause |
|---|---|
No UHD Devices Found on a healthy node |
The sb6 SFP+ port has no address. See Resources above. |
Address already in use on port 8000 |
An earlier server. listen-http now clears it; otherwise pkill -f fm_http.py or use another port. |
Unit ... was already loaded |
An interrupted run left the transmitter going. systemctl stop cosmos-fm-tx. |
| Audio but no station name | RDS needs a few seconds of carousel. If it never arrives, check the pilot is present. |
| Left and right swapped | The side_ref sign — see §2.2. |
| Part 5: audio, but no pilot and no RDS | The dongle is tuned at the carrier. It must be offset-tuned — see §5.3. |
Part 5: No supported devices found |
The kernel's DVB-T driver has the dongle. The experiment blacklists it; if you are running by hand, modprobe -r dvb_usb_rtl28xxu. |
| Part 5: level near 0 dBFS, mush | Clipping. Lower the gain — 12.5 dB is right for a strong local station. |
| Part 5: dongle vanishes mid-capture | A marginal USB port browns out when streaming starts. Use a rear port, not a front panel or hub. |
Every bug found while building this was silent: the chain ran and produced plausible output that was wrong.
The general lesson: in DSP, "it produced output" is not evidence. Verify each stage against something you can predict independently — a known pilot, a known station name, a theoretical BER — before believing the stage after it.
This tutorial is pinned to sb6 for physics, not convenience — and a portability test across
three other domains confirmed the pin:
| Domain | Hardware | Result |
|---|---|---|
sb5 (B210 pair, adjacent) |
OTA at low gain | Audio + stereo pilot decode cleanly; RDS does not assemble groups |
grid (B210 pair, ~17 m apart) |
OTA | Audio decodes; RDS worse still (indoor multipath at 100 MHz) |
sb7 (N210 + SBXv3) |
— | Impossible: SBX daughterboards cover 400–4400 MHz, below-range at 100.1 MHz |
Why RDS fails off-sb6: the decoder recovers the RDS symbol grid assuming transmitter and
receiver share a clock reference, which the cabled sb6 X310 pair provides. Free-running B210
TCXOs drift tens of ppm apart (39 ppm measured), slipping the fixed sampling grid. A
pilot-derived resampling correction recovers the block rate but a residual block-sequencing
defect remains under investigation — until the decoder gains true timing recovery, the full
stereo+RDS experience needs the sb6 pair. The transmitter chain, FM demodulation and stereo
decoding are fully portable to any USRP covering 88–108 MHz.
roles/fm_broadcast/ in cosmos_lab.orchestration — Part 5 adds iq_source.py (the USRP/dongle split) and tasks/rtl.ymlroles/fm_broadcast/ in cosmos_lab.orchestration — the DSP (music.py, fmstereo.py, rds.py, fm_tx.py, fm_stream.py, fm_http.py) and the role that stages itPart 5 last verified: 2026-09-22 on grid node20-18 (R820T dongle, VHF whip at the window), run from the packaged experiment: 88.7 MHz decoded PI 0x8374, station WRSU-FM and the full RadioText on 548/548 CRC-valid RDS blocks at −9.5 dBFS, the browser player served 1.2 MB of audio in 10 s, and an empty channel correctly failed with exit 1.
Parts 1–4 last verified: 2026-08-05 on sb6 (node1-1 transmitting, node1-2 receiving), end to end from a bare cosmos-run: pilot locked at -19.4 dB, stereo -9.7 dB, 71 RDS groups, station COSMOSFM and the full RadioText recovered, and /stream.wav serving 16-bit stereo WAVE to a browser.