RDS (Radio Data System) is a digital communication standard that transmits data as an inaudible signal modulated onto FM broadcasts (87.5–108 MHz). It was standardised under ETSI EN 50067 and has been in use across Europe since the 1980s. In North America, a nearly identical standard exists called RBDS.
RDS carries a variety of continuous digital metadata alongside the analog audio signal. The most commonly used data fields are:
| Code | Name | Meaning | Size |
|---|---|---|---|
| PI | Programme Identification | Unique 16-bit station identifier (hex, e.g. D3C3). Essential for auto-tuning. | 16 bit |
| PS | Programme Service Name | Station name, max. 8 characters (e.g. "NRJ ") | 8 × 8 bit |
| RT | RadioText | Scrolling text, max. 64 characters (title, artist, show name) | 64 × 8 bit |
| PTY | Programme Type | Programme category (0–31, e.g. 10 = Pop Music) | 5 bit |
| TP | Traffic Programme | Flag indicating if the station broadcasts traffic announcements | 1 bit |
| TA | Traffic Announcement | Flag indicating if a traffic announcement is currently active | 1 bit |
| AF | Alternate Frequencies | List of alternative frequencies carrying the same programme | 8 bit each |
| ECC | Extended Country Code | Extended country identifier (combined with PI nibble to determine country) | 8 bit |
RDS transmits at 1,187.5 bit/s as a BPSK signal on a 57 kHz subcarrier. A complete basic data block containing PI, PTY, TP, and two characters of the PS name requires 104 bits (26 bits per block × 4 blocks). A complete 8-character PS transmission takes approximately 0.35 seconds under perfect, error-free conditions.
Under local reception conditions with a strong antenna, RDS decoding is instantaneous and error-free. However, during long-distance FM reception (DX) – especially via tropospheric ducting, meteor scatter, or Sporadic-E propagation – signals are often extremely weak, rapidly fading, and severely affected by multipath interference or co-channel interference.
When the signal-to-noise ratio drops, the native RDS decoder built into the receiver chips (like TEF6686 / NXP) struggles:
Version 2.4 brings highly requested administrative features to the front-end, visualizes deep propagation data in the UI, and radically overhauls the database matching algorithms to favor smart propagation scoring over simple line-of-sight distance.
The Statistics panel now displays rich context for the matched FMDX.org reference. When a match occurs, the UI renders:
Previously, when multiple transmitters shared the same PI code, the algorithm simply selected the physically closest mast. Version 2.4 introduces the calculatePropagationScore() algorithm which uses real-world RF propagation physics to pick the most logical transmitter:
A brand new LOCAL DB section has been added to the main panel. It actively lists all stored memory entries specifically tied to the currently tuned frequency. It shows the stored PI, the resolved PS string, and the historical seen-count.
For users logged in as Administrators, the new LOCAL DB list features a red ✕ next to every entry. Clicking this executes an immediate WebSocket command (rdsm_delete_pi) to safely purge phantom, ghost, or misidentified PI codes from your persistent local rdsm_memory.json. A confirmation dialog prevents accidental clicks.
Dragging the RDS panel around the screen is no longer temporary. The UI now saves its exact pixel coordinates in your browser's localStorage and automatically restores the panel to your preferred screen location across page reloads.
sanitizeDatabaseWithFmdx routines introduced in v2.3 remain active and have been further optimized in 2.4.
The plugin is strictly split into two layers:
datahandler.js when RDS Follow Mode is enabled.The station name (PS) consists of 8 character positions. Each position is voted on separately. The receiver hardware flags every 16-bit block with an "Error Level" from 0 (perfect) to 3 (unrecoverable).
When the AI Decoder receives a raw PS character (e.g. at position 0), it looks at the hardware error level:
These points are accumulated in the server's memory. As time passes, the points decay (using a 7-day half-life formula). This allows the plugin to "forget" old corrupted data while strongly favoring a consistent signal. After enough votes, a reliable station name emerges.
On startup (and automatically every 6 hours) the plugin downloads the full FM transmitter database from maps.fmdx.org/api/?qth={lat},{lon} centred on the receiver's own location. All transmitters within a radius of 3000 km are indexed into two lightning-fast in-memory lookup tables.
Each fmdx.org entry provides:
Once a PS name has been determined with high confidence, it is locked – the displayed name stops changing until the frequency or PI code changes. This totally eliminates UI flickering.
The lock is achieved through three priority levels:
The Hybrid Case System: Many stations transmit their PS names in ALL CAPS (e.g. ANTENNE ) but they look terrible in the UI. FMDX.org contains beautifully formatted mixed-case variants (e.g. Antenne ). The plugin uses a Hybrid Constructor: if the live received uppercase letter mathematically matches the reference mixed-case letter at the exact same position, the plugin swaps in the beautiful mixed-case letter before sending it to the web server.
Before a PS name is fully locked, the decoder goes through an intermediate provisional stage. This state is communicated to the user via the STATUS row in the panel.
| State | Badge colour | Condition | Additional info shown |
|---|---|---|---|
| WAIT | Dark grey (neutral) | Default on start / after frequency change. Not enough data yet. | "collecting…" |
| PROVISIONAL | Amber (#c8a020) | A candidate PS exists with confidence ≥ 55% but is not yet locked. | Confidence % · stable time in seconds (e.g. "72% · stable 1.5s") |
| LOCKED | Green (#44ff88) | Confidence ≥ 90% and stable, OR strong fmdx.org match, OR raw-verified. | Reason for lock (e.g. "FMDX match 100%" or "DB verified string") |
When you change frequencies, the native decoder usually blanks out completely. The AI plugin behaves differently. Because it stores historical data in rdsm_memory.json, the moment you tune to a new frequency, it looks up known transmitters for that frequency.
If it finds a station in its memory that matches the frequency, it instantly prepares the "Predicted PS" and displays it in the statistics panel, even before a single RDS packet has been received from the antenna. If RDS Follow Mode is enabled, this cached prediction is immediately pushed to the web UI.
Some stations unfortunately use their PS field to transmit scrolling text (song titles, phone numbers) instead of a static station name. This is officially illegal under the RDS standard but widely practiced (especially in Italy and Eastern Europe).
The AI decoder features a Dynamic Jump Engine. It maintains a rolling buffer of the last 8 full strings received. If it detects that the strings are shifting sideways (scrolling) or constantly alternating between vastly different words, it flags the PI code as isDynamic: true.
When a station is flagged as dynamic, the statistical voting engine is completely bypassed, because voting on scrolling text produces absolute garbage (a mix of letters from different words). Instead, the plugin switches to a raw pass-through mode for that station.
The Alternate Frequency list is transmitted as a highly compressed set of 8-bit codes. The plugin decodes these codes according to the standard table (Method A):
Code 1 = 87.6 MHz ... Code 204 = 107.9 MHz
The decoded frequencies are maintained in a Set to guarantee uniqueness. In the UI, the AF badge illuminates and displays the total count. Hovering over the badge reveals the exact list of frequencies.
By default, the AI Decoder operates purely as an invisible observer. It draws its own floating statistics panel but does not alter the main web interface (the large blue PS blocks and RadioText banner at the top of the FM-DX-Webserver page). The native TEF hardware controls the main UI.
Clicking the RDS Follow button at the bottom of the stats panel takes the AI active. Once activated, the AI plugin forcefully intercepts the data pipeline and injects its mathematically stabilized, FMDX-verified, hybrid-cased PS strings and RadioText directly into the main web server UI. The flickering native decoder is completely overridden.
Certain PI codes are considered invalid or uninformative by the standard, particularly 0000 and FFFF. Cheap transmitters or pirate stations often use these.
The plugin has a special bypass logic for these. If it detects 0000 or FFFF, it skips database lookup, skips FMDX verification, and skips statistical voting. It acts purely as a raw pass-through, allowing you to see the raw text of pirate stations without the AI attempting to "correct" them into known stations.
In many countries (like Germany or France), national networks broadcast identical programming on dozens of transmitters, but insert local news for 5 minutes an hour. During those 5 minutes, they switch their PI code to a "Regional" PI code.
The plugin supports this flawlessly. The FMDX database download brings both the Primary PI and the Regional PI. If the plugin detects the Regional PI, it mathematically treats it exactly the same as the Primary PI. The station will lock instantly without needing to rebuild its statistical voting profile from scratch.
The floating AI panel is divided into sections:
A new feature in v2.4. When the statistics panel is expanded (by clicking the STATISTICS button), and a valid FMDX database match occurs, a golden highlighted section appears.
It displays:
🌐 fmdx.org [Station Name]match: [XX]% - How closely the live characters match the reference.[Tx Site Name] ([ITU Code]) - Example: Berlin/Scholzplatz (D).[ERP] kW [Polarization] · [Distance] km · [Azimuth]°This allows DXers to instantly verify not just what station they are receiving, but exactly where it is transmitting from, its power output, and which direction to point their directional yagi antennas.
Also new in v2.4, the main panel now features a LOCAL DB section at the very bottom.
This UI dynamically queries the server's rdsm_memory.json and extracts all stations that the plugin has ever seen on the currently tuned frequency. It shows the PI code, the locked PS string, whether it was flagged as dynamic (⚡), and how many packets it has historically received.
Deletion: Sometimes the TEF hardware generates a "Ghost PI" - a totally fake PI code generated by random noise that unfortunately passes the CRC check. If the plugin logs this, it will appear in the Local DB list. Logged-in Administrators will see a red ✕ next to the entry. Clicking this deletes the phantom station from the server's permanent memory immediately.
An RDS data stream consists of consecutive "Groups", each 104 bits long. A group is split into 4 Blocks (A, B, C, D) of 26 bits each. Each block contains 16 bits of payload data and 10 bits for a Checkword/Offset word used for Error Detection and Correction.
The native receiver chip evaluates the 10-bit checkword and assigns an error level array (e.g. errB = [0, 0, 1, 3]) for the four blocks:
The AI decoder strictly ignores any payload data from a block that has an error level of 2 or 3. Only levels 0 and 1 are permitted into the voting algorithms.
The plugin downloads roughly 30MB of JSON data from the FMDX APIs. It parses this immense list of global transmitters and filters out anything further than 3000km from your GPS location.
In version 2.4, selecting the correct transmitter when multiple sites share a PI code is no longer based purely on shortest distance. The new calculatePropagationScore(ref, dbEntry) function calculates a weighted score based on physics:
function calculatePropagationScore(ref, dbEntry) {
if (!ref) return 0;
const distKm = ref.distKm || 9999;
let distScore = 0;
// Distance Tiers
if (distKm <= 100) distScore = 100;
else if (distKm <= 300) distScore = 80;
else if (distKm <= 800) distScore = 40;
else if (distKm <= 2500) distScore = 20;
else distScore = 5;
// Power (ERP) factor: Logarithmic scale up to 50 points
const erp = ref.erp || 0.1;
const pwrScore = Math.min(50, Math.log10(Math.max(1, erp * 10)) * 15);
// Site Verification: Cross-referencing active AFs
let siteBonus = 0;
if (ref.txName) {
let sharedSites = 0;
for (const f of currentState.afSet) {
const freqRefs = getFreqRefs(f);
if (freqRefs.some(r => r.txName === ref.txName)) sharedSites++;
}
siteBonus = Math.min(30, sharedSites * 10);
}
// Sporadic-E characteristic check
let spEBonus = 0;
if (distKm > 800 && distKm < 2500) {
const cleanCount = countCleanRawPositions();
if (cleanCount >= 4) spEBonus = 20;
}
// Historical confirmation bonus
let histBonus = 0;
if (dbEntry && dbEntry.seenCount > 10) histBonus = 15;
return distScore + pwrScore + siteBonus + spEBonus + histBonus;
}
The checkAndLockPS(pi) function is the core supervisor of the UI state. It runs every time a new RDS packet arrives. It evaluates the current live buffer against the memory and the reference.
It uses a strict priority ladder:
entry.psVerifiedRaw), use it.psVerifiedRaw.The client UI calculates the STATUS string based on boolean flags and confidences sent by the server:
psProvisionalConf must exceed 0.55 (55%) to move from WAIT to PROVISIONAL.psStableMs measures how long the provisional string has remained totally unchanged.psLocked is a boolean. When true, the UI turns green and displays the psLockReason.Votes are stored in rdsm_memory.json in a compact format to prevent file bloat. Instead of storing an array of 5,000 "N"s, it stores an aggregated weight object:
"ps": {
"0": {
"N": {
"w": 45.2,
"count": 120,
"firstSeen": 1712849200,
"lastSeen": 1712850000
}
}
}
The weight (w) decays mathematically based on the time elapsed since lastSeen, using the VOTE_HALFLIFE_DAYS constant (7 days).
Confidence is a floating point number between 0.0 and 1.0. It is calculated by blending multiple factors:
To prevent a random hardware bit-flip from generating a totally fake PI code and polluting the database, the server requires a PI code to be verified multiple times before it is considered "real".
The threshold for verification is dynamic. The getConfirmThreshold(pi) function lowers the requirement if there is strong evidence the PI is real. For example, if the PI exists in the FMDX database for the tuned frequency at a distance < 500km, the threshold drops to 1 (instant verification). If it's totally unknown, it requires multiple identical packets.
The local database rdsm_memory.json is saved automatically every 60 seconds (DB_SAVE_INTERVAL). On exit (SIGINT/SIGTERM), an emergency save is fired.
To prevent infinite growth, the memory is capped at MAX_STATIONS = 2000. When saving, the server purges:
STATION_EXPIRE_DAYS (90 days).QUICK_EXPIRE_DAYS (7 days).The client and server communicate via a standard JSON WebSocket protocol. The server broadcasts payloads of type: 'rdsm_ai' continuously.
In version 2.4, a new client-to-server command was added for Admin deletion:
{
"type": "rdsm_delete_pi",
"pi": "D3C3"
}
When the server receives this, it deletes the D3C3 key from the db object and sets dbDirty = true. The client optimistically updates its own UI instantly.
To override the native datahandler.js, the server plugin intercepts the variables right before they are dispatched via the main Webserver WebSockets. It overwrites dataHandler.dataToSend.ps and dataHandler.dataToSend.pi with the mathematically verified AI strings. It also forces dataHandler.dataToSend.ps_errors = '0,0,0,0,0,0,0,0' to tell the native UI to stop flickering.
The client UI calculates the "AF Coverage %". It extracts the array of Alternate Frequencies from the active FMDX.org JSON reference block. It then compares this array against the live currentState.afSet (the AFs currently being decoded from the live radio signal). The overlap percentage is rendered in the UI.
The UI creates a horizontal scrolling list of "Frequency Chips". The chips turn bright blue if the frequency is actively being received, and stay grey if it is listed in the database but currently missing from the live signal.
The server plugin connects to its *own* local websocket at `ws://127.0.0.1:8080/data_plugins` to listen for GPS payloads. If a connected TEF receiver has a GPS module attached, or if a user inputs manual coordinates into the web interface, the server catches the type: 'GPS' payload.
To prevent noisy GPS modules from triggering a 30MB FMDX index rebuild every 5 seconds, the coordinates are rounded to 2 decimal places (`roundGps`), and a rebuild is only triggered if the distance moved exceeds FMDX_REINDEX_MIN_DIST_KM (100km).
The frontend Javascript hooks into the `mouseup` event of the drag handler. It reads `el.style.left` and `el.style.top` and dumps a JSON object into `window.localStorage.setItem('rdsm_panel_pos')`. On page load, this is parsed and injected directly into the DOM CSS properties, guaranteeing the panel stays exactly where you left it.
While the AI Decoder communicates primarily over WebSockets for live data, it exposes a silent footprint on the main HTTP API of the FM-DX Webserver.
When polling the /api endpoint of the server, standard RDS fields are replaced by the AI if Follow Mode is active:
{
"freq": "104.600",
"pi": "D3B8",
"ps": "RTL ",
"pty": 10,
"rds": true,
"ta": 0,
"tp": 1,
"ms": 1,
"af": [89000, 104600],
"rt0": "RTL - BERLINS HITRADIO",
"country_name": "Germany",
"country_iso": "DE"
}
If Follow mode is disabled, the /api returns the native hardware decoding results, which may contain spaces or errors like "R L ".
Advanced users can tweak the constants at the top of rds-ai-decoder_server.js to tune the AI behavior for specific DXing styles (e.g. meteor scatter vs tropo).
| Parameter | Default | Description |
|---|---|---|
VOTE_HALFLIFE_DAYS |
7 | Days before a vote loses 50% of its weight. Lower to adapt faster to stations changing their PS names. |
VOTE_EXPIRE_DAYS |
30 | Days before an old vote is completely purged from memory. |
STATION_EXPIRE_DAYS |
90 | Days before a station you haven't received is totally deleted from rdsm_memory.json. |
FMDX_RADIUS_KM |
3000 | Radius in km for downloading transmitters. 3000km covers Sporadic-E within Europe. Increase to 5000 if expecting double-hop SpE. |
PROVISIONAL_MIN_CONF |
0.55 | Confidence threshold (0.0 to 1.0) to enter the Amber PROVISIONAL state. |
LOCK_MIN_CONF |
0.90 | Confidence threshold to turn Green and LOCK the PS name permanently. |
LOCK_MIN_STABLE_MS |
700 | Time in milliseconds the provisional string must remain completely unchanged before locking is permitted. |
This happens when the FMDX database variant uses special characters, differing casing, or abbreviations that mathematically clash with the raw data. Ensure you have given the station enough time to decode cleanly. If the station recently rebranded, the FMDX database might simply be out of date. The AI will eventually lock onto the Raw RDS string instead of the FMDX variant.
The signal is too weak, or the TEF chip is dropping every packet with Error Level 2 or 3. The plugin aggressively filters out garbage data. If the hardware cannot deliver a single Level 1 packet, the AI has nothing to vote on.
If you are still tuned to the frequency and the hardware is still decoding that exact PI code from the noise floor, the plugin will instantly recreate the memory entry. Tune away to a blank frequency before purging ghost PI codes.
You must be logged into the FM-DX-Webserver as an Administrator. The standard Webserver authentication applies. Because RDS Follow forcefully overrides the main display for everyone viewing the server remotely, guest access to this toggle is strictly prohibited.
Ensure your GPS hardware has a solid lock. The plugin mitigates GPS jitter by rounding to ~1km and requiring a 100km total movement before triggering the 30MB FMDX index rebuild. If you see constant console logs about "fmdx.org index built", your coordinates in `config.json` might be oscillating wildly due to a bad script pushing fake GPS data to the websocket.