📡 RDS AI Decoder – Documentation v2.0
📡

RDS AI Decoder

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

Table of Contents

1What is RDS?3
2Why an AI Plugin?3
3What's New in Version 2.04
4Architecture – The Two Plugin Components5
5Simple Explanation – How the Plugin Works6
5.1Learning by Voting6
5.2fmdx.org Reference Database7
5.3PS Lock Engine and Hybrid Case8
5.4Frequency Change and Pre-Cache9
5.5Dynamic vs. Static Stations10
5.6Alternate Frequency (AF) Decoding10
5.7RDS Follow Mode11
5.8The Panel – Display Elements12
6Technical Deep Dive13
6.1RDS Group Structure and Error Correction13
6.2fmdx.org Bulk Index and Distance Filter14
6.3PS Lock Engine – Priority Ladder15
6.4Voting Engine – Compact Aggregation Format16
6.5Confidence Calculation17
6.6PI Verification Pipeline18
6.7Database Format and Storage Management19
6.8WebSocket Protocol21
6.9dataHandler Patch – Property Locking22
6.10Bigram Predictor23
6.11GPS WebSocket Listener24
7REST API24
8Configuration Parameters25

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
AFAlternate FrequenciesList of frequencies carrying the same programme8 bit each
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, and since v2.0 also cross-referenced against the live fmdx.org transmitter database for instant identification with correct mixed-case PS names.

3 · What's New in Version 2.0

Version 2.0 is a major upgrade over v1.0. The following table summarises the most important additions and changes:

Featurev1.0v2.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)

4 · Architecture – The Two Plugin Components

┌──────────────────────────────────────────────────────────────────────┐ │ WEB SERVER (Node.js) │ │ │ │ ┌────────────┐ RDS data stream ┌──────────────────────────────┐ │ │ │ Receiver │ ─────────────────▶ │ datahandler.js │ │ │ │ Hardware │ │ (native decoder) │ │ │ │ TEF6686 / │ └──────────────┬───────────────┘ │ │ │ TEF6687 │ │ Patch-Hook │ │ └────────────┘ ┌────────────────────────▼─────────────┐ │ │ │ rds-ai-decoder_server.js │ │ │ │ │ │ │ │ • fmdx.org Bulk Index (3000 km) │ │ │ │ • Voting Engine + PS Lock │ │ │ │ • Hybrid mixed-case PS │ │ │ │ • AF / ECC / Country Decode │ │ │ │ • DB Management (versioned) │ │ │ │ • AI Prediction Builder │ │ │ │ • PI Verification Pipeline │ │ │ │ • dataHandler property locking │ │ │ └──────────────┬────────────────────────┘ │ │ │ │ │ maps.fmdx.org ◀───── HTTP fetch ──────┤ │ │ config.json ◀──── lat/lon / port ─────┤ │ │ rdsm_memory.json ◀───── persist ──────┤ │ │ rdsm_fmdx_cache.json ◀── bulk cache ──┘ │ └──────────────────────────────────────────────────────┬───────────────┘ │ WebSocket /data_plugins│ ┌──────────▼────────────┐ │ User's Browser │ │ │ │ rds-ai-decoder.js │ │ • Panel rendering │ │ • Fusion logic │ │ • FMDX.ORG variant │ │ chips + matching │ │ • AF flag display │ │ • BER display │ └───────────────────────┘

5 · 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. 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.

5.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. In v2.0 the fmdx.org database delivers the name even faster, and with correct mixed-case spelling.

5.2 · fmdx.org Reference Database NEW in v2.0

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:

Strict PI gate: The plugin only displays an fmdx.org reference entry when the received PI code is explicitly listed for the exact currently tuned frequency. A PI that appears on a different frequency in the database does not count as a match and is silently skipped.
Offline / no location: If no valid lat/lon is found in 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.

5.3 · PS Lock Engine and Hybrid Case NEW in v2.0

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:

Priority A – DB verified string: Previously stored psVerifiedRaw ─────▶ LOCK immediately (entire PS received error-free in one round, stored in DB) Priority B – fmdx.org match ≥ 75%: Live buffer matches a known PS variant ─▶ LOCK with hybrid PS (raw RDS case preserved where characters agree with reference) Priority C – Full raw verification: All 8 positions received with errLevel ≤ 1, all characters non-space, one complete round ─▶ LOCK + store ───────────────────────────────────────────────────────── Example: fmdx.org has "Antenne" (mixed case) Raw RDS sends "ANTENNE" (all caps) Hybrid result: "Antenne" ← fmdx.org case wins per position where the uppercase letters match

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.

5.4 · Frequency Change and Pre-Cache

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

① Frequency changed (e.g. to 104.0 MHz) ├─▶ psLocked = false, lastBroadcastPS = null ├─▶ fmdxByFreq["104.00"] loaded → freqRefs array ├─▶ Database searched for known stations on 104.0 MHz │ Result: PI=D3C3 known (PS="NRJ ", seen 847×) │ Status: Prediction loaded – NOT YET DISPLAYED └─▶ rdsm_freq broadcast with reset:true sent to browser ② First raw packet (PI=D3C3, errB[0] ≤ 1) ├─▶ freqRefs contain D3C3 AND distKm < 500 km? │ → threshold = 1 → already confirmed! └─▶ OR threshold = 2 → piConfirmCount = 1 ③ Second raw packet (PI=D3C3, errB[0] ≤ 1) [if threshold=2] ├─▶ piConfirmCount = 2 ≥ threshold ├─▶ piConfirmed = true └─▶ onPIConfirmed(pi): fmdx.org seed applied to DB (if no local votes yet) AI prediction broadcast → plugin panel ✓ checkAndLockPS() → PS may lock immediately ✓ Web server UI: PI + PS + country set ✓

5.5 · 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:

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

5.6 · Alternate Frequency (AF) Decoding NEW in v2.0

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:

5.7 · 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 │ │ │ │ v2.0: Native decoder still runs on the FULL raw stream │ │ (for RDS Expert / external tools via rdsWss) but its │ │ dataToSend writes are blocked via property locking. │ │ After the native decoder returns, all locked fields are │ │ restored to the AI values. │ │ Switchable by administrators only. │ └─────────────────────────────────────────────────────────────────┘
v2.0 improvement: In v1.0, Follow mode stripped RDS lines from the data passed to the native decoder, breaking external tools like RDS Expert. In v2.0, the complete raw stream is always passed through unchanged. The AI values are protected by JavaScript property locks that intercept write attempts by the native decoder, then restored after its execution.

5.8 · The Panel – Display Elements

┌──────────────────────────────────────────────────────┐ │ 📡 RDS AI Decoder 🟢 ✕ │ ← Connection LED ├──────────────────────────────────────────────────────┤ │ FREQ │ 104.00 MHz │ │ PI CODE │ D3C3 │ ├──────────────────────────────────────────────────────┤ │ PS │ A n t e n n e · │ ← mixed-case chars │ │ ██ ██ ██ ██ ██ ██ ██ ░░ │ ← confidence bars ├──────────────────────────────────────────────────────┤ │ PTY │ Pop Music [10] │ ├──────────────────────────────────────────────────────┤ │ RT │ previous: "Artist - Title" │ │ │ current: "New Artist - Song" │ ├──────────────────────────────────────────────────────┤ │ FLAGS │ [TP] [TA] [MUSIC] [STEREO] [AF 3] [ECC E1]│ ├──────────────────────────────────────────────────────┤ │ GROUPS │ 0A 0B 1A 2A 2B 4A ... │ ├────���─────────────────────────────────────────────────┤ │ FMDX.ORG │ Antenne Bayern │ ← station name │ │ [Antenne ] [antenne ] [ANTENNE ] │ ← variant chips │ │ ■ blue=100% match ■ dark=no match │ ├──────────────────────────────────────────────────────┤ │ Groups:42 [RDS Follow] BER ██░░░░░░ 12% │ └──────────────────────────────────────────────────────┘
ElementMeaning
Connection LEDGreen = WebSocket connected, Red = disconnected
PS character brightnessWhite = high confidence; gold = fmdx.org confirmed (ref-match); amber = fmdx.org seed (ref-seed); dark = low confidence; near-black = bigram guess
Confidence barsWidth and opacity show the certainty of each character; gold colour for fmdx.org-sourced positions
RT previousLast fully received RadioText (before A/B flag change)
RT currentCurrently received RadioText with per-character confidence colouring
AF flagShows number of known alternate frequencies; tooltip lists all in MHz
ECC flagShows Extended Country Code hex value when received
FMDX.ORG rowStation 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 matrixLights up when the respective RDS group type is received
BER barBit Error Rate (0% = perfect, 100% = all blocks lost): green <20%, orange 20–50%, red >50%

6 · Technical Deep Dive

6.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
0APS name (2 chars), AF (2 codes), TA, TP, MS, DIPS voting, AF decoding, flags
0BPS name (2 chars), TA, TP, MS, DIPS voting, flags NEW
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. AF codes are only decoded when Block C also has errB[2] ≤ 1. A correctly received Block D with an erroneous Block B is discarded.

6.2 · fmdx.org Bulk Index and Distance Filter NEW in v2.0

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:

maps.fmdx.org JSON response │ ├─▶ extractLocations() – finds the locations object regardless of │ JSON structure variant │ ├─▶ For each transmitter: │ • Compute Haversine distance from own location │ • Skip if distKm > FMDX_RADIUS_KM (3000 km) │ • For each station on this transmitter: │ – Parse PS variants via parsePSVariants() │ (splits on spaces, pads to 8 chars, preserves case) │ – Store entry: { pi, psVariants[], station, lat, lon, distKm } │ ├─▶ Index: fmdxByFreq[roundFreq(freq)] = [entry, ...] │ sorted by distKm ascending │ └─▶ Cache to rdsm_fmdx_cache.json { _ts, raw } TTL = FMDX_BULK_TTL_MS (6 hours)

Reference matching at runtime

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
}

6.3 · PS Lock Engine – Priority Ladder NEW in v2.0

The PS lock is managed by checkAndLockPS(pi), called after every complete PS round (all 4 segments received):

psLocked = false (initial / after freq or PI change) │ ├─ Priority A: entry.psVerifiedRaw exists? │ YES → newPS = psVerifiedRaw │ (optionally sync to best fmdx variant if score ≥ 80%) │ psLocked = true ────────────────────────┐ │ │ ├─ Priority B: bestScore (fmdx.org) ≥ 0.75? │ │ YES → newPS = buildHybridPS(bestVariant) │ │ psLocked = true ────────────────────────┤ │ │ ├─ Priority C: allRawVerified? │ │ (all 8 errBuf ≤ 1, all non-space, │ │ full round received after piConfirmed) │ │ YES → newPS = psBuf.join('') │ │ entry.psVerifiedRaw = newPS │ │ psLocked = true ────────────────────────┤ │ ▼ └─ psLocked = false (keep trying) lastBroadcastPS = newPS ────────────────────────────────────────────────────────────────── Dynamic Jump (psLocked = true, multiple variants exist): │ ├─ fmdx.org has ≥ 2 variants AND live buffer matches │ a DIFFERENT variant with score ≥ 0.75? │ YES → lastBroadcastPS = buildHybridPS(newVariant) │ entry.psIsDynamic = true └─ (PS stays locked, just switches variant)

Hybrid PS construction

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;
}

6.4 · Voting Engine – Compact Aggregation Format

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:

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.

6.5 · 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)

// Upper bound: 0.97 (never 100% confidence from DB alone)

Confidence Scale – Colour Mapping in the Panel

conf = 1.00 (raw-0)
rgb(240)
conf = 0.90 (ai-high)
rgb(218)
conf = 0.75 (ai-mid)
rgb(185)
conf = 1.00 (ref-match)
gold
conf = 0.50 (ref-seed)
amber
conf = 0.10 (ai-bigram)
rgb(18)

Normal: rgb(v) where v = 20 + conf × 220  ·  ref-match: warm gold rgb(r,g,b)  ·  ref-seed: amber  ·  bigram: v = 15 + conf × 30

PS source types (v2.0)

src valueMeaningConfidence range
raw-0Received directly, error-free1.00
raw-1Received directly, corrected0.90
raw-2Received, unreliable (client only)0.70
ai-dynamicFrom dynamic PS last-raw buffer0.90
ai-voted-highFrom DB, conf ≥ 0.900.85–0.95
ai-voted-midFrom DB, conf 0.50–0.900.50–0.85
ai-voted-lowFrom DB, conf < 0.500.30–0.50
ref-matchfmdx.org match score ≥ 0.500.50–0.90 (gold)
ref-seedfmdx.org initial seed, low match0.30–0.50 (amber)
ai-bigramStatistical guess, no DB entry0.03–0.25
emptyNo data point available0 (transparent)

6.6 · PI Verification Pipeline CHANGED in v2.0

The pipeline is extended in v2.0 with distance-aware confirmation thresholds and fmdx.org seeding:

Frequency change │ ├─▶ clearRDSInDataHandler() piConfirmed=false, piConfirmCount=0 ├─▶ psLocked=false, lastBroadcastPS=null ├─▶ freqRefs = fmdxByFreq[freq] (fmdx.org entries for this freq) ├─▶ findKnownPIForFreq() load DB seed internally └─▶ broadcast rdsm_freq { reset:true, stats:{...} } First raw packet (PI=XXXX, errB[0]≤1) │ ├─▶ pi ≠ currentState.pi? │ └─▶ new PI candidate: piConfirmCount=1, piConfirmed=false │ psLocked=false, lastBroadcastPS=null │ psBuf/psErrBuf/psSegsSeen reset │ └─▶ threshold = getConfirmThreshold(pi): freqRefs has PI AND distKm < 500 km → threshold = 1 freqRefs has PI, distKm ≥ 500 km → threshold = 2 PI not in freqRefs → threshold = 2 piConfirmCount ≥ threshold? └─▶ piConfirmed = true onPIConfirmed(pi): Seed DB from fmdx.org if no local votes yet broadcast rdsm_ai prediction → plugin panel ✓ dataHandler.pi/ps/rds set → web UI ✓ applyFollowToDataHandler() → if Follow active ✓ checkAndLockPS() → PS may lock immediately ✓
Ghost PI suppression: In v2.0 a constant 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.

6.7 · Database Format and Storage Management CHANGED in v2.0

File structure (rdsm_memory.json)

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

DB Version Migration

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.

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 produces no duplicates
Useful thresholdPS or ECC or PTY>0 or AFSparse entries without useful data are quick-expired

fmdx.org cache file (rdsm_fmdx_cache.json)

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

6.8 · WebSocket Protocol CHANGED in v2.0

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, RT, AF list, psName/Variants, stats)
rdsm_freqServer → ClientFrequency change with reset:true and full stats object
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

Example rdsm_ai payload (v2.0)

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

Example rdsm_freq payload (v2.0)

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

6.9 · dataHandler Patch – Property Locking CHANGED in v2.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.

6.10 · Bigram Predictor CHANGED in v2.0

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.

6.11 · GPS WebSocket Listener NEW in v2.0

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.

7 · REST API CHANGED in v2.0

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

EndpointMethodDescription
/api/rdsm/statsGETPlugin overall status: station count, fmdx.org frequency count, operating modes, current PI, current frequency, psLocked

Example response for /api/rdsm/stats

{
  "stationCount":  312,
  "fmdxFreqCount": 4872,       // NEW: frequencies in fmdx.org index
  "rdsFollowMode": false,
  "aiExclusiveMode": false,
  "piConfirmed":   true,
  "psLocked":      true,         // NEW
  "currentPI":     "D3C3",
  "currentFreq":   "104.00"
}

8 · Configuration Parameters

ConstantValueDescription
PI_CONFIRM_THRESHOLD2Default number of matching error-free packets before PI is confirmed
GHOST_PI_THRESHOLD8Consecutive error-free groups needed for an unrecognised PI (debug info)NEW
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
DB_VERSION1Triggers one-time DB wipe when loading an older databaseNEW
CONF_TABLE[1.0, 0.9, 0.7, 0.0]Confidence values for errLevel 0–3
FMDX_RADIUS_KM3000Maximum distance of transmitters included in the fmdx.org indexNEW
FMDX_BULK_TTL_MS6 hTime-to-live for the fmdx.org bulk cacheNEW

RDS AI Decoder · Documentation v2.0 · Author: Highpoint · March 2026
github.com/Highpoint2000/RDS-AI-Decoder