📡 RDS AI Decoder – Documentation
📡

RDS AI Decoder

Plugin Documentation
Server Plugin: rds-ai-decoder_server.js
Client Plugin: rds-ai-decoder.js
Version: 1.0
Author: Highpoint
Date: March 2026
v1.0

Table of Contents

1What is RDS?3
2Why an AI Plugin?3
3Architecture – The Two Plugin Components4
4Simple Explanation – How the Plugin Works4
4.1Learning by Voting5
4.2Frequency Change and Pre-Cache5
4.3Dynamic vs. Static Stations6
4.4RDS Follow Mode6
4.5The Panel – Display Elements7
5Technical Deep Dive8
5.1RDS Group Structure and Error Correction8
5.2Voting Engine – Compact Aggregation Format9
5.3Confidence Calculation10
5.4PI Verification Pipeline11
5.5Database Format and Storage Management12
5.6WebSocket Protocol13
5.7dataHandler Patch13
5.8Bigram Predictor14
6REST API14
7Configuration Parameters15

1 · What is RDS?

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:

CodeNameMeaningSize
PIProgramme IdentificationUnique 16-bit station identifier (hex, e.g. D3C3)16 bit
PSProgramme Service NameStation name, max. 8 characters (e.g. "NRJ ")8 × 8 bit
RTRadioTextScrolling text, max. 64 characters (title, artist etc.)64 × 8 bit
PTYProgramme TypeProgramme category (0–31, e.g. 10 = Pop Music)5 bit
TPTraffic ProgrammeStation broadcasts traffic announcements1 bit
TATraffic AnnouncementTraffic announcement currently active1 bit
ECCExtended Country CodeExtended 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.

2 · Why an AI Plugin?

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:

Plugin goal: By collecting and statistically evaluating many received packets – including erroneous ones – a more reliable picture of the station is reconstructed than a simple real-time decoder can provide. Known stations are recognised immediately from the database.

3 · Architecture – The Two Plugin Components

┌─────────────────────────────────────────────────────────────────┐ │ WEB SERVER (Node.js) │ │ │ │ ┌────────────┐ RDS data stream ┌─────────────────────┐ │ │ │ Receiver │ ──────────────────────▶ │ datahandler.js │ │ │ │ Hardware │ │ (native decoder) │ │ │ │ TEF6686 / │ └──────────┬──────────┘ │ │ │ TEF6687 │ │ Patch-Hook │ │ └────────────┘ ┌──────────▼──────────┐ │ │ │ ai-predictor_ │ │ │ │ server.js │ │ │ │ │ │ │ │ • Voting Engine │ │ │ │ • DB Management │ │ │ │ • AI Prediction │ │ │ │ • PI Verification │ │ │ └──────────┬──────────┘ │ │ │ │ │ rdsm_memory.json ◀────────┘ │ └────────────────────────────────────────────────────┼────────────┘ │ WebSocket /data_plugins│ ┌──────────▼────────────┐ │ User's Browser │ │ │ │ ai-predictor.js │ │ • Panel rendering │ │ • Fusion logic │ │ • BER display │ └───────────────────────┘

4 · Simple Explanation – How the Plugin Works

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.

4.1 · Learning by Voting

The station name (PS) consists of 8 character positions. Each position is voted on separately:

Received packets for position 0: Packet 1: 'N' (error=0, weight=10) ──┐ Packet 2: 'N' (error=0, weight=10) │ Voting Packet 3: 'M' (error=1, weight= 5) │ for position 0 Packet 4: 'N' (error=0, weight=10) │ Packet 5: '?' (error=2, weight= 0) ──┘ (discarded) Result: 'N' = 30 points ← Winner 'M' = 5 points

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.

4.2 · Frequency Change and Pre-Cache

When tuning to a new frequency, the following sequence runs:

① Frequency changed (e.g. to 104.0 MHz) └─▶ Internally: database searched for known stations on 104.0 MHz Result: PI=D3C3 known (PS="NRJ ", seen 847×) Status: Prediction loaded – NOT YET DISPLAYED ② First raw packet received (PI=D3C3, Block A error-free) └─▶ piConfirmCount = 1 – not enough yet ③ Second raw packet received (PI=D3C3, Block A error-free) └─▶ piConfirmCount = 2 ≥ threshold (2) piConfirmed = true NOW: Panel displays PI + PS instantly (from DB) Web server UI displays PI + PS Autologger receives verified PI
Why wait for 2 packets? A single received packet could contain a false PI code due to noise. Two matching error-free Block A packets rule this out practically – and the time cost is only about 90–180 ms on a typical RDS stream.

4.3 · Dynamic vs. Static Stations

Some stations scroll their PS name or change it regularly (e.g. to display song information). The plugin detects this automatically:

TypeDetectionBehaviour
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
Note for DXers: Under weak reception a normally static station may be incorrectly flagged as dynamic (error-induced character variations). In this case no voting is performed – the displayed PS reflects the most recently received low-error raw value.

4.4 · RDS Follow Mode

The plugin operates in two modes:

┌─ Follow OFF (default) ───────────────────────────────────┐ │ │ │ Hardware ──▶ native decoder ──▶ web server display │ │ Hardware ──▶ plugin ──▶ plugin panel │ │ │ │ Both decoders run in parallel. The plugin shows its │ │ improved data only in its own panel. │ └──────────────────────────────────────────────────────────┘ ┌─ Follow ON ──────────────────────────────────────────────┐ │ │ │ Hardware ──▶ plugin ──▶ web server display ← AI data! │ │ plugin ──▶ plugin panel │ │ │ │ The native decoder is disabled for RDS data. │ │ The plugin takes over completely. Autologgers and │ │ fmlist receive the AI-improved data. │ │ Switchable by administrators only. │ └──────────────────────────────────────────────────────────┘

4.5 · The Panel – Display Elements

┌──────────────────────────────────────────────┐ │ 📡 RDS AI Decoder 🟢 ✕ │ ← Connection LED ├──────────────────────────────────────────────┤ │ FREQ │ 104.000 MHz │ │ PI CODE │ D3C3 │ ├──────────────────────────────────────────────┤ │ PS │ N R J · · · · · │ ← characters │ │ ██ ██ ██ ░░ ░░ ░░ ░░ ░░ │ ← confidence bars ├──────────────────────────────────────────────┤ │ PTY │ Pop Music [10] │ ├──────────────────────────────────────────────┤ │ RT │ previous: "Artist - Title" │ │ │ current: "New Artist - Song" │ ├──────────────────────────────────────────────┤ │ FLAGS │ [TP] [TA] [MUSIC] [STEREO] [ECC] │ ├──────────────────────────────────────────────┤ │ GROUPS │ 0A 0B 1A 2A 2B 4A ... │ ├──────────────────────────────────────────────┤ │ Groups:42 [RDS Follow] BER ████░░ 78% │ └──────────────────────────────────────────────┘
ElementMeaning
Connection LEDGreen = WebSocket connected, Red = disconnected
PS character brightnessWhite = high confidence, dark grey = low, near-black = bigram guess
Confidence barsWidth and opacity show the certainty of each individual character
RT previousLast fully received RadioText (before A/B flag change)
RT currentCurrently received RadioText with confidence colouring
Group matrixLights up when the respective RDS group type is received
BER barBit Error Rate of last 60 packets: Green >80%, Orange 50–80%, Red <50%

5 · Technical Deep Dive

5.1 · RDS Group Structure and Error Correction

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.

BlockContentError level meaning
API code (always present)0=clean, 1=corrected, 2=unreliable, 3=lost
BGroup type, version, flagsas above
C / C'Group-specificas above
DGroup-specificas above

The plugin receives these error levels as the array errB[0..3] and converts them into confidence weights:

errLevelMeaningVote weightCONF_TABLE
0Error-free101.00
1Corrected (≤2 bits)50.90
2Erroneous, unreliable0 (no vote)0.70
3Block lost— (discarded)0.00

Group types and their relevance to the plugin

GroupContentPlugin usage
0A / 0BPS name (2 chars), TA, TP, MS, DIPS voting, flags
1AProgramme Item Number, ECC, LanguageECC storage, country lookup
2ARadioText 64 chars (4 per group)RT assembly
2BRadioText 32 chars (2 per group)RT assembly
4AClock time and datenot used
14A/BEnhanced Other Networks (EON)not used
Quality gate: PS votes are only accepted when both Block A (errB[0] ≤ 1) and Block B (errB[1] ≤ 1) are error-free or corrected. A correctly received Block D with an erroneous Block B is discarded – the group identification in Block B could otherwise be misinterpreted.

5.2 · Voting Engine – Compact Aggregation Format

Since version 4.0, a compact aggregated format is used instead of a list of individual timestamps:

Format per character position (JSON)

// 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)
}

Lazy Exponential Decay

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).

Consistency Boost

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
}

5.3 · Confidence Calculation

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)

Confidence Scale – Colour Mapping in the Panel

conf = 1.00
rgb(240)
conf = 0.90
rgb(218)
conf = 0.75
rgb(185)
conf = 0.50
rgb(130)
conf = 0.25
rgb(75)
conf = 0.00
rgb(20)
bigram guess
rgb(15–45)

RGB value = 20 + conf × 220 (normal) · 15 + conf × 30 (bigram)

5.4 · PI Verification Pipeline

From version 4.4 onwards every PI code passes through a strict verification pipeline before being delivered to any output:

Frequency change │ ├─▶ clearRDSInDataHandler() piConfirmed=false, piConfirmCount=0 ├─▶ findKnownPIForFreq() load DB seed internally │ currentState.pi = knownPI (NOT broadcast, NOT displayed) │ ▼ First raw packet (PI=XXXX, errB[0]≤1) │ ├─▶ pi ≠ currentState.pi? │ └─▶ onPIChange(): piConfirmCount=1, piConfirmed=false │ buildAIPrediction() → stored in currentState │ (NO output) │ ▼ Second raw packet (PI=XXXX, errB[0]≤1) ← same PI │ ├─▶ piConfirmCount++ = 2 ≥ PI_CONFIRM_THRESHOLD (2) ├─▶ piConfirmed = true └─▶ onPIConfirmed(pi): broadcast(prediction) → plugin panel ✓ dataHandler.pi = pi → web UI / autologger ✓ dataHandler.ps = ps → web UI ✓ applyFollowToDataHandler() → if Follow active ✓
Important for autologgers / fmlist: Only verified PI codes leave the server via the dataHandler. Pre-cache data stays internal. This prevents false PI codes from being logged during rapid scanning when brief co-channel interference is present.

5.5 · Database Format and Storage Management

File structure (rdsm_memory.json)

{
  "_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
  }
}

Retention periods and limits

RuleValueReason
Normal retention90 daysCover seasonal DX propagation periods
Quick expiry (sparse data)7 daysDon't retain single-packet PI errors indefinitely
Vote half-life7 daysAdapt to station name changes
Vote full expiry30 daysDon't carry stale data
Maximum stations2000Limit file size
Save interval60 secondsReduce I/O load
Frequency rounding±0.1 MHzDX tuning at ±0.01 MHz precision produces no duplicates
psDynamicBuf is a non-enumerable JavaScript property and is therefore automatically excluded by JSON.stringify(). It holds only runtime data (the last 8 PS strings for dynamic detection) and does not need to be persisted.

5.6 · WebSocket Protocol

Communication between the server plugin and the browser runs over WebSocket on the path /data_plugins. All messages are JSON.

Message typeDirectionMeaning
rdsm_rawServer → ClientRaw RDS packet (PI, blocks, error levels, frequency)
rdsm_aiServer → ClientAI prediction (PS slots with confidence, RT, stats)
rdsm_freqServer → ClientFrequency change notification
rdsm_rds_follow_stateServer ↔ ClientRDS Follow status (on/off)
rdsm_set_rds_followClient → ServerToggle RDS Follow (admin only)
rdsm_get_rds_followClient → ServerQuery current Follow status
rdsm_exclusive_stateServer → ClientAI exclusive mode status
rdsm_set_exclusiveClient → ServerToggle exclusive mode (admin + token)
rdsm_ai_frontendServer → MainWssNative RDS frontend update (exclusive mode)

Example rdsm_ai payload

{
  "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
}

PS source types

src valueMeaningConfidence range
ai-cached-dbFrom DB, ≥15 votes and ≥70% confidence0.70–0.97
ai-voted-highFrom DB, conf ≥ 0.900.85–0.93
ai-voted-midFrom DB, conf 0.70–0.900.66–0.88
ai-voted-lowFrom DB, conf < 0.700–0.66
ai-bigramStatistical guess, no DB entry0.03–0.25
raw-0Received directly, error-free1.00
raw-1Received directly, corrected0.90
emptyNo data point available0

5.7 · dataHandler Patch

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

5.8 · Bigram Predictor

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

6 · REST API

The plugin registers two HTTP endpoints for external queries:

EndpointMethodDescription
/api/rdsm/statsGETPlugin overall status: station count, current PI, frequency, operating modes, uptime
/api/rdsm/pi/{PI}GETDetail info for a PI code: resolved PS name, confidences, vote total, dynamic/static flag, ECC, seen counter

Example response for /api/rdsm/pi/D3C3

{
  "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
}

7 · Configuration Parameters

ConstantValueDescription
PI_CONFIRM_THRESHOLD2Number of matching error-free raw packets before PI is considered valid
VOTE_HALFLIFE_DAYS7Half-life of vote weight in days
VOTE_EXPIRE_DAYS30Votes older than N days are fully discarded
STATION_EXPIRE_DAYS90Inactive stations are deleted after N days
QUICK_EXPIRE_DAYS7Stations with ≤2 sightings and no useful data deleted after N days
MAX_STATIONS2000Maximum number of stations in the database
CONSISTENCY_BOOST1.5Weight multiplier when dominant character is confirmed again
AI_BROADCAST_DELAY80 msBatching delay for AI broadcasts (prevents flooding)
DB_SAVE_INTERVAL60 sPeriodic 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