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.
RDS carries among others the following information:
| Code | Name | Meaning | Size |
|---|---|---|---|
| PI | Programme Identification | Unique 16-bit station identifier (hex, e.g. D3C3) | 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 etc.) | 64 × 8 bit |
| PTY | Programme Type | Programme category (0–31, e.g. 10 = Pop Music) | 5 bit |
| TP | Traffic Programme | Station broadcasts traffic announcements | 1 bit |
| TA | Traffic Announcement | Traffic announcement currently active | 1 bit |
| AF | Alternate Frequencies | List of frequencies carrying the same programme | 8 bit each |
| ECC | Extended Country Code | Extended country identifier (combined with PI nibble) | 8 bit |
RDS transmits at 1,187.5 bit/s as a BPSK signal on a 57 kHz subcarrier. A complete PS transmission takes approximately 0.35 seconds under error-free conditions.
During long-distance FM reception (DX) – especially via tropospheric ducting or Sporadic-E propagation – signals are often weak and affected by multipath interference or co-channel interference. This results in:
Version 2.0 is a major upgrade over v1.0. The following table summarises the most important additions and changes:
| Feature | v1.0 | v2.0 |
|---|---|---|
| fmdx.org reference DB | Not present | Full bulk download from maps.fmdx.org/api/, cached locally for 6 h, radius 3000 km, indexed by frequency and PI |
| PS Lock Engine | Not present – PS always rebuilt from votes | Once a PS is verified (raw or fmdx.org match ≥ 75%), it is locked and frozen; dynamic jumps handled separately |
| Mixed-case PS names | All uppercase from voting | Hybrid PS: raw RDS case is preserved where it matches the fmdx.org reference; e.g. "Antenne" instead of "ANTENNE" |
| PS Variants | Not present | fmdx.org can provide multiple PS name variants for one station (e.g. "Radio 1" and "BBC R 1"); dynamic jump detects live variant switches |
| AF decoding | Not present | Group 0A Block C decoded, AF list cached per PI, displayed in panel flag and propagated in Follow mode |
| ECC / Country | Stored in DB, displayed in flag | Full ECC + PI-nibble → ISO 3166-1 lookup table; country name and ISO code in Follow mode output |
| Distance-aware PI confirmation | Fixed threshold = 2 | Threshold = 1 for stations within 500 km (fmdx.org), = 2 for distant; ghost PI suppression via GHOST_PI_THRESHOLD |
| dataHandler locking | Native decoder writes after AI; flickering possible | Object.defineProperty locks block the native decoder from overwriting AI data; values restored after each cycle |
| GPS integration | Static lat/lon from config.json | Optional live GPS via WebSocket (/data_plugins); own location updates rebuild the fmdx.org index on the fly |
| DB versioning / migration | No versioning | DB_VERSION = 1; old databases are automatically wiped once for mixed-case relearning, with RDS Follow state preserved |
| Panel – FMDX.ORG row | Not present | Station name header + colour-coded variant chips showing live match score (blue = 100%, gold = partial, dark = no match) |
| BER direction | Signal quality: 100% = perfect | Bit Error Rate: 0% = perfect, 100% = all blocks lost (standard convention) |
| Stats panel on freq change | Old values remain | Stats panel is immediately cleared on every frequency change |
| Group 0B | Not decoded | Fully decoded (PS, TA, MS, DI flags) |
| psVerifiedRaw in DB | Not present | Once all 8 PS characters are received error-free in a single round, the string is stored and takes priority over vote result |
| RDS character set | Minor typo: '+' at positions 0x29/0x2A |
Fixed: '*' at 0x2A, '+' at 0x2B (ETSI EN 50067 compliant) |
Imagine listening to an announcement in a busy train station with a lot of background noise. The first time you might only catch "...NR...", the second time "...NRJ...". After hearing it 10 times, you can say with high confidence: the announcement said "NRJ".
That is exactly what the plugin does with RDS data: it listens to many erroneous packets, collects them all, and votes statistically on which character at which position is most likely the correct one. In v2.0 it additionally consults the online fmdx.org transmitter database to cross-check and immediately confirm the station name – even before enough votes have been accumulated.
The station name (PS) consists of 8 character positions. Each position is voted on separately:
After enough votes, a reliable station name emerges. Through its database the plugin already knows thousands of stations – for recognised ones, the full name is available immediately. In v2.0 the fmdx.org database delivers the name even faster, and with correct mixed-case spelling.
On startup (and 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 an in-memory lookup table keyed by frequency.
Each fmdx.org entry provides:
config.json and no GPS fix is available, the fmdx.org download is skipped and the plugin falls back to voting-only mode. All other features continue to work normally.
The bulk download is cached on disk as rdsm_fmdx_cache.json. On restart the cache is loaded immediately (no cold-start delay) and a fresh download is scheduled for when the TTL (6 hours) expires.
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 eliminates the flickering that occurs when weak reception alternately produces correct and incorrect characters.
The lock is achieved through three priority levels:
After a lock is set, the dynamic jump engine handles stations with multiple PS variants (e.g. alternating station names). When the live buffer matches a different known variant at ≥ 75%, the locked PS jumps to the new variant without unlocking.
When tuning to a new frequency, the following sequence runs:
Some stations scroll their PS name or change it regularly (e.g. to display song information). The plugin detects this automatically:
| Type | Detection | v2.0 behaviour |
|---|---|---|
| Static | PS name stays constant | Votes accumulated, DB grows, PS locked once verified; fmdx.org variant chips shown |
| Multi-variant | fmdx.org lists 2+ PS variants | Dynamic jump engine active; locked PS switches to best-matching variant in real time |
| Scrolling | PS rotates (A→AB→B→BC...) | No voting; last raw value displayed; PS not locked; shown with isScrollingNonFmdx path |
| Changing | ≥2 recurring texts in 5+ rounds | No voting; last raw value displayed directly |
RDS Group 0A Block C carries up to two AF codes per group. The plugin decodes these codes according to the ETSI EN 50067 formula:
function decodeAFCode(code) { // code 1–204 → frequency in MHz: (code + 875) / 10 // e.g. code 1 → 87.6 MHz, code 204 → 107.9 MHz if (code >= 1 && code <= 204) return (code + 875) / 10; return null; // 205–255 are special codes (filler, LF/MF, ...) }
Decoded frequencies are:
entry.af[] (sorted, deduplicated)The plugin operates in two modes:
| Element | Meaning |
|---|---|
| Connection LED | Green = WebSocket connected, Red = disconnected |
| PS character brightness | White = high confidence; gold = fmdx.org confirmed (ref-match); amber = fmdx.org seed (ref-seed); dark = low confidence; near-black = bigram guess |
| Confidence bars | Width and opacity show the certainty of each character; gold colour for fmdx.org-sourced positions |
| RT previous | Last fully received RadioText (before A/B flag change) |
| RT current | Currently received RadioText with per-character confidence colouring |
| AF flag | Shows number of known alternate frequencies; tooltip lists all in MHz |
| ECC flag | Shows Extended Country Code hex value when received |
| FMDX.ORG row | Station name header (full name from fmdx.org) plus one chip per known PS variant; chip turns blue at 100% match, stays dark when no match |
| Group matrix | Lights up when the respective RDS group type is received |
| BER bar | Bit Error Rate (0% = perfect, 100% = all blocks lost): green <20%, orange 20–50%, red >50% |
An RDS group consists of 4 blocks of 26 bits each (16 bits payload + 10 bits CRC checksum). The error correction code is a shortened cyclic code capable of detecting up to 5 bit errors and correcting up to 2 bit errors per block.
| Block | Content | Error level meaning |
|---|---|---|
| A | PI code (always present) | 0=clean, 1=corrected, 2=unreliable, 3=lost |
| B | Group type, version, flags | as above |
| C / C' | Group-specific | as above |
| D | Group-specific | as above |
The plugin receives these error levels as the array errB[0..3] and converts them into confidence weights:
| errLevel | Meaning | Vote weight | CONF_TABLE |
|---|---|---|---|
| 0 | Error-free | 10 | 1.00 |
| 1 | Corrected (≤2 bits) | 5 | 0.90 |
| 2 | Erroneous, unreliable | 0 (no vote) | 0.70 |
| 3 | Block lost | — (discarded) | 0.00 |
| Group | Content | Plugin usage |
|---|---|---|
| 0A | PS name (2 chars), AF (2 codes), TA, TP, MS, DI | PS voting, AF decoding, flags |
| 0B | PS name (2 chars), TA, TP, MS, DI | PS voting, flags NEW |
| 1A | Programme Item Number, ECC, Language | ECC storage, country lookup |
| 2A | RadioText 64 chars (4 per group) | RT assembly |
| 2B | RadioText 32 chars (2 per group) | RT assembly |
| 4A | Clock time and date | not used |
| 14A/B | Enhanced Other Networks (EON) | not used |
After downloading the bulk transmitter data from maps.fmdx.org/api/?qth={lat},{lon}, the plugin builds an in-memory index. The data flows through the following pipeline:
When a PI is confirmed on frequency f:
function findBestRefEntry(pi) { const refs = fmdxByFreq[roundFreq(currentFreq)] || []; const exact = refs.filter(r => r.pi === pi.toUpperCase()); if (exact.length === 0) return null; // strict: PI must be on THIS freq return exact.sort((a,b) => a.distKm - b.distKm)[0]; }
The PS match score between the live raw buffer and each fmdx.org variant is computed position-aware using only clean positions (errB ≤ 1):
function computeRefMatchScore(pi) { const ref = findRefEntry(pi); if (!ref?.psVariants?.length) return 0; let best = 0; for (const variant of ref.psVariants) { const rv = variant.toUpperCase().padEnd(8, ' '); let m = 0, c = 0; for (let i = 0; i < 8; i++) { if (!cleanPositions[i]) continue; c++; if (rv[i] === buf[i].toUpperCase()) m++; } const s = c > 0 ? m/c : 0; if (s > best) best = s; } return best; // 0.0 – 1.0 }
The PS lock is managed by checkAndLockPS(pi), called after every complete PS round (all 4 segments received):
function buildHybridPS(referenceStr) { let hybrid = ""; const ref = referenceStr.padEnd(8, ' '); for (let i = 0; i < 8; i++) { const rawChar = psBuf[i] || ' '; const rawErr = psErrBuf[i]; const refChar = ref[i]; // Accept raw case where the letter matches the reference if (rawErr <= 2 && rawChar.toUpperCase() === refChar.toUpperCase()) hybrid += rawChar; // e.g. 'a' from raw, 'A' from ref → 'a' else hybrid += refChar; // reference character as fallback } return hybrid; }
A compact aggregated format is used instead of a list of individual timestamps:
// db[pi].ps[position][character] { "w": 147.3, // current weighted score (lazy-decayed) "count": 18, // raw number of votes received "firstSeen": 1773554046944, // Unix timestamp of first vote (ms) "lastSeen": 1773554677188 // Unix timestamp of last vote (ms) }
The weight w decays exponentially with a half-life of 7 days. The calculation is performed lazily – only on the next access:
function applyDecay(v, now) { const ageMs = now - v.lastSeen; const halfMs = 7 * 86400000; // 7 days in milliseconds v.w = v.w * Math.pow(0.5, ageMs / halfMs); v.lastSeen = now; }
Values below 0.1 weight with fewer than 3 votes are deleted on the next write access (noise suppression).
When a character already clearly dominates (weight > 2× all competitors combined), the new vote receives a multiplier of 1.5×. This accelerates convergence for unambiguous stations.
The confidence per PS slot is composed from three components:
conf = share * 0.5 // winner's share of total weight + dominance * 0.3 // gap between winner and runner-up + voteFactor * 0.2 // experience factor (saturates at 30+ votes) // Upper bound: 0.97 (never 100% confidence from DB alone)
Normal: rgb(v) where v = 20 + conf × 220 · ref-match: warm gold rgb(r,g,b) · ref-seed: amber · bigram: v = 15 + conf × 30
| src value | Meaning | Confidence range |
|---|---|---|
raw-0 | Received directly, error-free | 1.00 |
raw-1 | Received directly, corrected | 0.90 |
raw-2 | Received, unreliable (client only) | 0.70 |
ai-dynamic | From dynamic PS last-raw buffer | 0.90 |
ai-voted-high | From DB, conf ≥ 0.90 | 0.85–0.95 |
ai-voted-mid | From DB, conf 0.50–0.90 | 0.50–0.85 |
ai-voted-low | From DB, conf < 0.50 | 0.30–0.50 |
ref-match | fmdx.org match score ≥ 0.50 | 0.50–0.90 (gold) |
ref-seed | fmdx.org initial seed, low match | 0.30–0.50 (amber) |
ai-bigram | Statistical guess, no DB entry | 0.03–0.25 |
empty | No data point available | 0 (transparent) |
The pipeline is extended in v2.0 with distance-aware confirmation thresholds and fmdx.org seeding:
GHOST_PI_THRESHOLD = 8 is defined. When debug mode is active, any unrecognised PI (not in fmdx.org for the current frequency) is flagged in the log. This allows DXers to distinguish intentional DX receptions from spurious noise-generated PI codes.
{
"_meta": {
"rdsFollowMode": false, // restored on restart
"savedAt": 1773556231184,
"dbVersion": 1 // NEW: triggers one-time wipe if mismatch
},
"D3C3": {
"freq": "104.00", // rounded frequency (±0.1 MHz)
"ps": { "0": { "N": {w,count,firstSeen,lastSeen}, ... }, ... },
"psResolved": "NRJ ", // current best voting result
"psConf": [0.97,0.95,0.93,0,0,0,0,0],
"psVerifiedRaw": "NRJ ", // NEW: stored when all 8 chars verified
"psVerifiedRawTs": 1773554677188, // NEW: timestamp of verification
"psIsDynamic": false,
"psLastRaw": ["N","R","J"," "," "," "," "," "],
"psLastRawTs": 1773554677188,
"pty": 10,
"tp": true,
"ta": false,
"ms": 1,
"stereo": true,
"ecc": "E1",
"af": [89.5, 94.1, 98.0], // NEW: cached AF list (MHz)
"seen": 1773554677188,
"seenCount": 847
}
}
When the server starts and finds a database without _meta.dbVersion === 1, it performs a one-time automatic wipe. This clears the old uppercase-only votes so that mixed-case learning can begin from scratch. The rdsFollowMode setting is preserved across the wipe.
| Rule | Value | Reason |
|---|---|---|
| Normal retention | 90 days | Cover seasonal DX propagation periods |
| Quick expiry (sparse data) | 7 days | Don't retain single-packet PI errors indefinitely |
| Vote half-life | 7 days | Adapt to station name changes |
| Vote full expiry | 30 days | Don't carry stale data |
| Maximum stations | 2000 | Limit file size |
| Save interval | 60 seconds | Reduce I/O load |
| Frequency rounding | ±0.1 MHz | DX tuning at ±0.01 MHz produces no duplicates |
| Useful threshold | PS or ECC or PTY>0 or AF | Sparse entries without useful data are quick-expired |
{
"_ts": 1773556231184, // download timestamp
"raw": { ... } // original maps.fmdx.org response
}
The raw response is stored verbatim so that buildFmdxIndex() can be called again with updated own coordinates (e.g. after a GPS fix) without re-downloading.
Communication between the server plugin and the browser runs over WebSocket on the path /data_plugins. All messages are JSON.
| Message type | Direction | Meaning |
|---|---|---|
rdsm_raw | Server → Client | Raw RDS packet (PI, blocks, error levels, frequency) |
rdsm_ai | Server → Client | AI prediction (PS slots, RT, AF list, psName/Variants, stats) |
rdsm_freq | Server → Client | Frequency change with reset:true and full stats object |
rdsm_rds_follow_state | Server ↔ Client | RDS Follow status (on/off) |
rdsm_set_rds_follow | Client → Server | Toggle RDS Follow (admin only) |
rdsm_get_rds_follow | Client → Server | Query current Follow status |
{
"type": "rdsm_ai",
"pi": "D3C3",
"ps": [
{ "char": "A", "conf": 0.97, "src": "ref-match" },
{ "char": "n", "conf": 0.95, "src": "ref-match" },
{ "char": "t", "conf": 0.90, "src": "ai-voted-high" },
// ... 5 more slots
],
"rt": { "text": "Dua Lipa - Levitating", "score": 0.72, "src": "raw-rt" },
"af": [89.5, 94.1, 98.0], // NEW: AF list in MHz
"psName": "Antenne Bayern", // NEW: full station name
"psNameSrc": "fmdx", // NEW: 'fmdx' | null
"psVariants": ["Antenne ", "ANTENNE "], // NEW: all known PS variants
"stats": {
"freq": "104.00",
"seenCount": 847,
"psVoteTotal": 312,
"psIsDynamic": false,
"psLocked": true, // NEW
"refStation": "Antenne Bayern", // NEW
"refDistKm": 142, // NEW
"refMatchScore": 95 // NEW (0–100)
},
"ts": 1773556231184
}
{
"type": "rdsm_freq",
"freq": "104.00",
"reset": true, // NEW: signals full panel reset
"pi": null,
"ps": null,
"stats": { // NEW: initial zeroed stats
"freq": "104.00", "seenCount": 0, "psVoteTotal": 0,
"psIsDynamic": false, "psLocked": false,
"refStation": null, "refDistKm": null, "refMatchScore": 0
}
}
v1.0 stripped RDS lines from the native decoder's input stream, breaking external tools. v2.0 uses a different approach: the native decoder receives the complete raw stream, but all writes to the protected fields are intercepted via JavaScript property descriptors:
dh.handleData = function(wss, receivedData, rdsWss) { // 1. AI parsing runs first interceptLines(receivedData); if (aiExclusiveMode) return; if (rdsFollowMode && piConfirmed) { // 2. Apply AI values applyFollowToDataHandler(); // 3. Lock all RDS fields with no-op setters const locked = {}; fields.forEach(f => { locked[f] = dh.dataToSend[f]; Object.defineProperty(dh.dataToSend, f, { get: () => locked[f], set: () => {}, // native decoder writes are silently discarded configurable: true }); }); // 4. Run native decoder with FULL raw data (rdsWss/RDS Expert gets it all) const result = orig.call(this, wss, receivedData, rdsWss); // 5. Restore normal writable properties fields.forEach(f => { Object.defineProperty(dh.dataToSend, f, { value: locked[f], writable: true, configurable: true }); }); return result; } // Follow OFF: native decoder runs normally return orig.call(this, wss, receivedData, rdsWss); };
The locked fields include: pi, ps, ps_errors, pty, tp, ta, ms, rt0, rt1, rt0_errors, rt1_errors, rt_flag, ecc, country_iso, country_name, af.
The bigram predictor is an extended fallback of last resort. In v2.0 it is only reached when no raw data, no DB vote above 30% confidence, and no fmdx.org reference seed is available for a position. The table has been significantly expanded:
// Excerpt from the v2.0 bigram table: BIGRAM = { ' ': { 'R':25, 'S':20, 'M':18, 'F':15, ... }, 'F': { 'M':40, 'R':12, 'L': 8, ... }, 'A': { 'D':15, 'N':14, 'S':12, ... }, // ... 26 letter rows + digit rows } // Confidence of a bigram guess: conf = (bestScore / totalScore) * 0.25 // max. 25% – clearly a guess
In practice, bigram predictions are almost never visible to the user because one of the higher-priority sources (raw RDS, DB votes, or fmdx.org) fills every slot within a few seconds of reception.
The server plugin connects as a WebSocket client to its own /data_plugins endpoint to receive GPS messages from a connected GPS plugin:
// Message type: 'GPS' with value.lat, value.lon, value.status ws.on('message', (raw) => { const msg = JSON.parse(raw); if (msg.type === 'GPS' && msg.value.status === 'active') { if (lat !== ownLat || lon !== ownLon) { ownLat = lat; ownLon = lon; // Rebuild fmdx.org index from cached raw data with new location if (fmdxLoadedAt > 0) buildFmdxIndex(cachedRaw); } } });
Reconnection is attempted every 15 seconds on disconnect. The GPS listener starts 5 seconds after plugin initialisation to allow the GPS plugin to connect first. If no GPS data is received, the static location from config.json is used.
The plugin registers one HTTP endpoint. (The per-PI detail endpoint from v1.0 was removed in v2.0; all per-station information is now delivered via WebSocket in the rdsm_ai message.)
| Endpoint | Method | Description |
|---|---|---|
/api/rdsm/stats | GET | Plugin overall status: station count, fmdx.org frequency count, operating modes, current PI, current frequency, psLocked |
{
"stationCount": 312,
"fmdxFreqCount": 4872, // NEW: frequencies in fmdx.org index
"rdsFollowMode": false,
"aiExclusiveMode": false,
"piConfirmed": true,
"psLocked": true, // NEW
"currentPI": "D3C3",
"currentFreq": "104.00"
}
| Constant | Value | Description | |
|---|---|---|---|
PI_CONFIRM_THRESHOLD | 2 | Default number of matching error-free packets before PI is confirmed | |
GHOST_PI_THRESHOLD | 8 | Consecutive error-free groups needed for an unrecognised PI (debug info) | NEW |
VOTE_HALFLIFE_DAYS | 7 | Half-life of vote weight in days | |
VOTE_EXPIRE_DAYS | 30 | Votes older than N days are fully discarded | |
STATION_EXPIRE_DAYS | 90 | Inactive stations are deleted after N days | |
QUICK_EXPIRE_DAYS | 7 | Stations with ≤2 sightings and no useful data deleted after N days | |
MAX_STATIONS | 2000 | Maximum number of stations in the database | |
CONSISTENCY_BOOST | 1.5 | Weight multiplier when dominant character is confirmed again | |
AI_BROADCAST_DELAY | 80 ms | Batching delay for AI broadcasts (prevents flooding) | |
DB_SAVE_INTERVAL | 60 s | Periodic database save interval | |
DB_VERSION | 1 | Triggers one-time DB wipe when loading an older database | NEW |
CONF_TABLE | [1.0, 0.9, 0.7, 0.0] | Confidence values for errLevel 0–3 | |
FMDX_RADIUS_KM | 3000 | Maximum distance of transmitters included in the fmdx.org index | NEW |
FMDX_BULK_TTL_MS | 6 h | Time-to-live for the fmdx.org bulk cache | NEW |
RDS AI Decoder · Documentation v2.0 · Author: Highpoint · March 2026
github.com/Highpoint2000/RDS-AI-Decoder