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 |
| 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:
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.
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 after just 2 confirmed PI code packets.
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 | Behaviour |
|---|---|---|
| Static | PS name stays constant | Votes are accumulated, DB grows, confidence rises |
| Scrolling | PS rotates (A→AB→B→BC...) | No voting, last raw value displayed directly |
| Changing | ≥4 different texts in 5 rounds | No voting, last raw value displayed directly |
The plugin operates in two modes:
| Element | Meaning |
|---|---|
| Connection LED | Green = WebSocket connected, Red = disconnected |
| PS character brightness | White = high confidence, dark grey = low, near-black = bigram guess |
| Confidence bars | Width and opacity show the certainty of each individual character |
| RT previous | Last fully received RadioText (before A/B flag change) |
| RT current | Currently received RadioText with confidence colouring |
| Group matrix | Lights up when the respective RDS group type is received |
| BER bar | Bit Error Rate of last 60 packets: Green >80%, Orange 50–80%, 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 / 0B | PS name (2 chars), TA, TP, MS, DI | PS voting, flags |
| 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 |
Since version 4.0, 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, not as a background process:
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:
if (posVotes[char].w > competitorW * 2) { finalWeight = weight * 1.5; // CONSISTENCY_BOOST }
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) // Where: share = bestWeight / totalWeight dominance = (best - second) / totalWeight // when >1 candidate voteFactor = Math.min(1, totalCount / 30) // saturates at 30 votes // Upper bound: 0.97 (never 100% confidence from DB alone)
RGB value = 20 + conf × 220 (normal) · 15 + conf × 30 (bigram)
From version 4.4 onwards every PI code passes through a strict verification pipeline before being delivered to any output:
{
"_meta": {
"rdsFollowMode": false, // restored on restart
"savedAt": 1773556231184
},
"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],
"psIsDynamic": false,
"psLastRaw": ["N","R","J"," "," "," "," "," "],
"psLastRawTs": 1773554677188,
"pty": 10,
"tp": true,
"ta": false,
"ms": 1,
"stereo": true,
"ecc": "E1", // Extended Country Code (hex)
"seen": 1773554677188, // last seen (Unix ms)
"seenCount": 847 // total groups received
}
}
| 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 precision produces no duplicates |
JSON.stringify(). It holds only runtime data (the last 8 PS strings for dynamic detection) and does not need to be persisted.
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 with confidence, RT, stats) |
rdsm_freq | Server → Client | Frequency change notification |
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 |
rdsm_exclusive_state | Server → Client | AI exclusive mode status |
rdsm_set_exclusive | Client → Server | Toggle exclusive mode (admin + token) |
rdsm_ai_frontend | Server → MainWss | Native RDS frontend update (exclusive mode) |
{
"type": "rdsm_ai",
"pi": "D3C3",
"ps": [
{ "char": "N", "conf": 0.97, "src": "ai-cached-db" },
{ "char": "R", "conf": 0.95, "src": "ai-voted-high" },
{ "char": "J", "conf": 0.93, "src": "ai-voted-high" },
{ "char": "x", "conf": 0.05, "src": "ai-bigram" },
// ... 4 more slots
],
"rt": { "text": "Dua Lipa - Levitating", "score": 0.72, "src": "ai-rt-live" },
"stats": { "seenCount": 847, "psVoteTotal": 312.4, "freq": "104.00",
"psIsDynamic": false, "pty": 10 },
"ts": 1773556231184
}
| src value | Meaning | Confidence range |
|---|---|---|
ai-cached-db | From DB, ≥15 votes and ≥70% confidence | 0.70–0.97 |
ai-voted-high | From DB, conf ≥ 0.90 | 0.85–0.93 |
ai-voted-mid | From DB, conf 0.70–0.90 | 0.66–0.88 |
ai-voted-low | From DB, conf < 0.70 | 0–0.66 |
ai-bigram | Statistical guess, no DB entry | 0.03–0.25 |
raw-0 | Received directly, error-free | 1.00 |
raw-1 | Received directly, corrected | 0.90 |
empty | No data point available | 0 |
The plugin hooks into the native WebSDR server's handleData() function using a JavaScript function replacement (monkey-patch):
const orig = dh.handleData; dh.handleData = function(wss, receivedData, rdsWss) { // 1. Our processing runs first interceptLines(receivedData); // 2. Exclusive mode: bypass native decoder entirely if (aiExclusiveMode) return; // 3. Follow mode: strip RDS lines from the stream const dataForNative = nativeRDSDisabled ? stripRDSLines(receivedData) : receivedData; // 4. Call native decoder with (possibly filtered) data const result = orig.call(this, wss, dataForNative, rdsWss); // 5. Follow mode: overwrite native data with AI data if (rdsFollowMode && piConfirmed) applyFollowToDataHandler(); return result; }; dh._rdsm_patched = true; // prevents double-patching
When no database entry exists for a PS position, a statistical estimate is made based on the preceding character (bigram model). The table contains typical frequencies of two-character combinations found in station names:
// Excerpt from the bigram table: BIGRAMS = { ' ': { 'R':25, 'F':20, 'M':15, ... }, // after space, R is common 'F': { 'M':40, 'R':12, 'L': 8, ... }, // after F, M is common (FM...) 'R': { 'D':20, 'A':15, 'I':12, ... }, // after R, D is common (Radio) } // Confidence of a bigram guess: conf = (bestScore / totalScore) * 0.25 // max. 25% – clearly identifiable as a guess
The plugin registers two HTTP endpoints for external queries:
| Endpoint | Method | Description |
|---|---|---|
/api/rdsm/stats | GET | Plugin overall status: station count, current PI, frequency, operating modes, uptime |
/api/rdsm/pi/{PI} | GET | Detail info for a PI code: resolved PS name, confidences, vote total, dynamic/static flag, ECC, seen counter |
{
"freq": "104.00",
"psResolved": "NRJ ",
"psConf": [0.97, 0.95, 0.93, 0, 0, 0, 0, 0],
"psVoteTotal": 312,
"psIsDynamic": false,
"psLastRaw": "NRJ ",
"psLastRawAge": "42s",
"pty": 10,
"tp": true,
"ta": false,
"ecc": "E1",
"seen": 1773554677188,
"seenCount": 847
}
| Constant | Value | Description |
|---|---|---|
PI_CONFIRM_THRESHOLD | 2 | Number of matching error-free raw packets before PI is considered valid |
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 |
CONF_TABLE | [1.0, 0.9, 0.7, 0.0] | Confidence values for errLevel 0–3 |
RDS AI Decoder · Documentation v1.0 · Author: Highpoint · March 2026
github.com/Highpoint2000/RDS-AI-Decoder