Educational SDR laboratory for extracting and decoding Radio Data System (RDS) information from a commercial FM broadcast station. The RDS subcarrier is isolated from the FM multiplex, translated to baseband, synchronized, interpreted as a BPSK signal, and decoded into valid RDS groups.
This repository follows a technical and educational style: real IQ capture, FM demodulation, spectral analysis, FIR filtering, carrier recovery, BPSK visualization, bit extraction, RDS block validation, Program Identification (PI), Program Service name (PS), and RadioText (RT) reconstruction.
The
figuras_rds_A/folder name is intentionally preserved because the analysis script writes figures there by default. The included images are placeholders and should be replaced by the real plots generated after running Part A.
- src β Python code for SDR capture, demodulation, and RDS decoding
- results β real capture logs and Part B summary figure
The measurements were performed using a low-cost RTL-SDR USB dongle connected to a standard FM broadcast antenna.
- SDR Receiver: RTL-SDR (RTL2832U compatible)
- Frequency Band: FM broadcast band
- Interface: USB
- Antenna: Wideband FM antenna
- Local FM station with RDS transmission.
If an IQ capture is already available in .npz format, the RTL-SDR is not required.
The RTL-SDR is used exclusively for IQ data acquisition, while all signal processing is performed offline in Python.
| Script | Purpose |
|---|---|
src/part_a_rds_analysis.py |
Captures or loads IQ data, demodulates FM, analyzes the FM multiplex, extracts the RDS component, validates BPSK behavior, and generates didactic figures. |
src/part_b_rds_decoding.py |
Processes the IQ capture, searches for valid RDS groups, consolidates the dominant PI, reconstructs PS by segment voting, and extracts RadioText when enough segments are available. |
- Capture or load IQ samples from an FM broadcast station.
- Demodulate FM to obtain the composite multiplex signal.
- Identify the main spectral components of the FM multiplex:
- mono audio band,
- 19 kHz stereo pilot,
- stereo difference region,
- 57 kHz RDS subcarrier.
- Isolate the RDS band using FIR filtering.
- Translate the RDS subcarrier to baseband.
- Recover the BPSK-like symbol stream.
- Extract a binary RDS bitstream.
- Validate RDS blocks using syndrome checks.
- Decode and consolidate:
- PI: Program Identification,
- PS: Program Service name,
- RT: RadioText.
In FM broadcasting, RDS is transmitted around the 57 kHz subcarrier inside the composite multiplex signal. This frequency is the third harmonic of the 19 kHz stereo pilot:
The RDS symbol rate is:
In this implementation, the extracted RDS signal is resampled to:
which gives:
samples per RDS bit/symbol interval.
The FM multiplex is obtained from the phase difference between consecutive IQ samples:
where x[n] is the complex IQ signal.
The RDS component is isolated using a bandpass filter around the 57 kHz subcarrier:
Two routes are implemented:
| Route | Description |
|---|---|
piloto_19k_al_cubo |
Uses the 19 kHz stereo pilot raised to the third power to generate a 57 kHz reference. |
oscilador_fijo_57k |
Uses a fixed 57 kHz complex oscillator. |
Using both routes improves robustness when different stations exhibit different RDS recovery behavior.
A BPSK Costas loop is used to stabilize the recovered baseband phase. A PCA-based rotation is then applied to align the BPSK symbol cloud with the in-phase axis.
The RDS bit decision metric compares the first and second halves of each bit interval:
The sign of this metric is used to estimate the corresponding bit state.
Each RDS block has:
16 information bits + 10 check bits = 26 bits
A complete RDS group contains four blocks:
A | B | C/C' | D
Therefore, one full RDS group contains:
The decoder searches for valid A, B, C/C', and D blocks, filters by the dominant PI, removes duplicates, and consolidates the final information.
The most relevant figures are reserved in figuras_rds_A/. Replace the placeholder images with the real figures generated by the script.
A real off-air FM capture was performed using an RTL-SDR receiver tuned to Radio Carolina 98.9 MHz.
This result is included as the main experimental validation case of the repository.
The capture demonstrates a realistic scenario: the RDS Program Service name was fully recovered, and the RadioText message was reconstructed almost completely from received RDS groups.
| Item | Value |
|---|---|
| FM station | Radio Carolina |
| Frequency | 98.9 MHz |
| RF sample rate | 2.048 MS/s |
| Capture duration | 65 s |
| RTL-SDR gain | 30 dB |
| IQ samples | 133,120,000 |
| Tuner detected | Rafael Micro R828D |
| Item | Result |
|---|---|
| MPX sample rate | 228 kHz |
| MPX duration | 65.00 s |
| Main RDS route | 19 kHz pilot cubed |
| Estimated residual RDS offset | -0.86 Hz |
| Raw RDS groups found | 104 |
| Consolidated RDS groups | 13 |
| Dominant PI | 0xCB1A |
| Program Service | CAROLINA |
| PS segments | 4/4 |
| Part A total time | 80.36 s |
Part A confirms that the RDS component around 57 kHz was successfully extracted, translated to baseband, synchronized, and interpreted as a valid BPSK-like signal.
| Item | Result |
|---|---|
| Search mode | Generic AUTO |
| Main route used | piloto_19k_al_cubo |
| Estimated residual offset | -0.86 Hz |
| Useful RDS duration | 64.74 s |
| Raw detected groups | 272 |
| PI count | 0xCB1A: 272 |
| Consolidated groups | 20 |
| Program Service | CAROLINA |
| PS segments | 4/4 |
| RT segments received | 12/16 |
| Part B total time | 115.87 s |
Recovered Program Service:
CAROLINA
Recovered RadioText:
Escribenos un Whatsapp 569 61246007 ????arolina 98.9
The missing ???? characters correspond to incomplete RadioText segment coverage during the capture. This is expected in a real broadcast scenario when the receiver stops before all RadioText segments are repeated. Even so, the message is reconstructed almost entirely, which validates that the processing chain is working with real RDS data rather than a synthetic example.
The full console logs are included in:
results/radio_carolina_98_9_part_a_console_output.txt
results/radio_carolina_98_9_part_b_console_output.txt
Install the main dependencies:
pip install numpy scipy matplotlibFor direct RTL-SDR capture:
pip install pyrtlsdrOn Linux, RTL-SDR system libraries may also be required:
sudo apt install rtl-sdr librtlsdr-devPart A performs IQ capture or IQ loading, FM demodulation, multiplex analysis, RDS extraction, BPSK visualization, and figure generation.
python src/part_a_rds_analysis.pyBefore running, review the configuration section at the beginning of the script:
STATION_MHZ = 98.9
CAPTURE_SECONDS = 65.0
GAIN_DB = 30.0
CAPTURE_NEW = False
SAVEFIGS = TrueRelevant parameters:
| Parameter | Description |
|---|---|
STATION_MHZ |
Selected FM station frequency. |
CAPTURE_SECONDS |
IQ capture duration. |
GAIN_DB |
RTL-SDR gain. |
CAPTURE_NEW |
If True, captures from RTL-SDR. If False, loads an existing capture. |
SAVEFIGS |
Saves the generated figures. |
AUTO_IQ_FILENAME |
Automatically names captures based on station frequency. |
STRICT_STATION_MATCH |
Verifies that the IQ capture matches the configured station. |
Part B processes the same IQ capture and attempts to recover valid RDS groups.
python src/part_b_rds_decoding.pyThis stage reports:
- raw detected RDS groups,
- dominant PI,
- consolidated groups,
- PS segments,
- PS by voting,
- RadioText segments,
- reconstructed RadioText when enough segments are available.
The scripts include automatic synchronization search options so the lab is not tied to a single radio station.
SEARCH_MODE = "AUTO"For general laboratory use, AUTO is recommended.
When AUTO_IQ_FILENAME = True, the IQ capture name is generated from the selected frequency.
Example:
STATION_MHZ = 98.9produces:
fm_rds_iq_98_9MHz.npz
This avoids accidentally analyzing an old capture from a different station.
A successful decoding run may produce a console summary similar to:
============================================================
GLOBAL RESULT
============================================================
Raw detected groups: XX
Detected PI count:
0xXXXX: XX
Dominant PI: 0xXXXX
Consolidated groups: XX
PS by voting:
segment 0: 'XX' votes=X
segment 1: 'XX' votes=X
segment 2: 'XX' votes=X
segment 3: 'XX' votes=X
Consolidated PS: 'XXXXXXXX'
PS segments: [True, True, True, True]
RadioText:
group 2A segment 0: '....'
group 2A segment 1: '....'
Consolidated RT: 'Recovered text from the FM station'
This project can be used in courses or workshops related to:
- analog communications,
- digital communications,
- software-defined radio,
- FM demodulation,
- stereo FM multiplexing,
- FIR filtering,
- spectral analysis,
- BPSK modulation,
- carrier recovery,
- symbol synchronization,
- digital frame decoding,
- error detection and block validation.
- Refactor the processing chain into reusable Python modules.
- Add command-line arguments with
argparse. - Export decoded PI, PS, and RT results to JSON or CSV.
- Add a Jupyter notebook for teaching demonstrations.
- Include a LaTeX laboratory guide.
- Compare multiple FM stations in a single report.
- Add unit tests for the RDS syndrome and block validation functions.
- Add an optional graphical interface for selecting frequency and capture settings.
- GNU Radio β Official Documentation: GNU Radio
- Frequency Modulation (FM) β Wikipedia: Wikipedia Frequency Modulation
- FM broadcasting β Wikipedia: FM broadcasting
- RDS β Wikipedia: RDS
This project is intended for educational and experimental purposes only.
It is provided to demonstrate signal processing concepts related to FM broadcast reception and spectrum analysis.
The author does not encourage or endorse any unauthorized or improper use of radio equipment.
Users are responsible for ensuring compliance with local laws and regulations regarding radio reception and spectrum usage.
Support me on Patreon https://www.patreon.com/c/CrissCCL
MIT License








