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.
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 | sb6 — two X310s, cabled through a fixed 60 dB attenuator |
| Band | 100.1 MHz, inside coax. Nothing radiates. |
| Client needs | A browser and ssh. Nothing to install. |
| Topic group | SDR & GNU Radio |
| Last verified | 2026-08-05 |
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"
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"
| 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 |
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. |
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 — the DSP (music.py, fmstereo.py, rds.py, fm_tx.py, fm_stream.py, fm_http.py) and the role that stages itLast 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.