📡 RDS AI Decoder – Documentation v2.1
📡

RDS AI Decoder

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

Table of Contents

1What is RDS?3
2Why an AI Plugin?3
3What's New in Version 2.14
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.8Special / Wildcard PI Codes12
5.9Regional PI Codes (PIreg)12
5.10The Panel – Display Elements13
6Technical Deep Dive14
6.1RDS Group Structure and Error Correction14
6.2fmdx.org Bulk Index, Distance Filter and PIreg15
6.3PS Lock Engine – Priority Ladder17
6.4Voting Engine – Compact Aggregation Format18
6.5Confidence Calculation19
6.6PI Verification Pipeline20
6.7Special PI Pass-Through21
6.8Database Format and Storage Management22
6.9WebSocket Protocol24
6.10dataHandler Patch – Property Locking26
6.11FMDX.ORG Panel – Frequency Chips and AF Coverage27
6.12Bigram Predictor28
6.13GPS WebSocket Listener29
6.14Manual Link in the Panel Header29
7REST API30
8Configuration Parameters31

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. Version 2.1 further strengthens this with regional PI (PIreg) support, special PI pass-through, alternative frequency coverage display, and an in-panel link to the online manual.

3 · What's New in Version 2.1

Version 2.1 builds on the foundation of v2.0 with a focused set of precision improvements centred on regional PI handling, special/wildcard PI pass-through, and enhanced fmdx.org frequency coverage display.

Featurev2.0v2.1
Regional PI code (PIreg) NEW Not present – only primary PI used for fmdx.org matching fmdx.org index stores both pi and pireg per station. findBestRefEntry() tries primary PI first, then falls back to pireg. Stats payload exposes pireg and piMain back-reference fields so the panel can indicate a regional variant is active.
Special / wildcard PI codes NEW All PI codes processed identically isSpecialPI() detects FFFF (test/wildcard) and 0000 (invalid). Special PIs are never stored in the DB, never voted on, and never locked. Their raw PS/RT/flags pass through directly in a dedicated code path both server-side and in the AI prediction builder.
Filtered AF lookup by confirmed PS NEW getAltFreqsForPI() returns all fmdx.org entries for a PI regardless of name getAltFreqsForPIAndPS() narrows the result to entries whose psVariants match the locked PS string. Falls back to the full list if no filtered results are found.
Alternative frequency coverage display NEW FMDX.ORG panel row showed name + variant chips only The FMDX.ORG panel row now additionally shows: (1) an AF coverage percentage badge (AF m/n (x%)) counting how many of the fmdx.org-listed frequencies have been received live; (2) a scrollable chip row of all known fmdx.org frequencies, colour-coded blue when received, dark when not yet seen. Scroll position is preserved across re-renders.
Per-entry psVariants in altFreqs NEW altFreqs list contained only freq, distKm, station Each entry in the altFreqs array now also carries a psVariants[] field. The frequency chip tooltip in the panel shows the known PS name variants for that transmitter.
Manual link in panel header NEW No link to documentation from the panel A small ? icon link in the panel header bar (left of the connection LED) opens the online manual at https://highpoint.fmdx.org/manuals/RDS-AI-Decoder-Documentation-v2.1.html.
Stats: pireg / piMain fields NEW Stats object did not expose regional PI information The stats object in every rdsm_ai message and in rdsm_freq now contains pireg (regional PI code of the matched station, if any) and piMain (the primary PI code when the match was found via pireg).
fmdxByPI reverse index NEW Not present – only frequency-keyed lookup fmdxByPI maps every known PI and PIreg code to the list of frequencies it appears on. Used by getAltFreqsForPI() and getAltFreqsForPIAndPS() to show alternative frequencies in the panel without an additional frequency scan.

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 v2.1 │ │ │ │ │ │ │ │ • fmdx.org Bulk Index (3000 km) │ │ │ │ – primary PI + PIreg indexed │ │ │ │ – fmdxByFreq + fmdxByPI │ │ │ │ • Special PI pass-through (FFFF/0000)│ │ │ │ • Voting Engine + PS Lock │ │ │ │ • Hybrid mixed-case PS │ │ │ │ • AF / ECC / Country Decode │ │ │ │ • Filtered AF lookup (PS-aware) │ │ │ │ • 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 │ │ v2.1 │ │ • Panel + manual │ │ link (header) │ │ • Fusion logic │ │ • FMDX.ORG section: │ │ – variant chips │ │ – AF coverage % │ │ – freq chip scroll │ │ • 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. Version 2.1 extends this by also handling regional PI codes that some networks use instead of a single national PI code, and by adding a clean pass-through path for special/wildcard PI codes that must never be stored.

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.

5.2 · fmdx.org Reference Database

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 two in-memory lookup tables: one keyed by frequency (fmdxByFreq), and since v2.1 also one keyed by PI code (fmdxByPI).

Each fmdx.org entry provides:

Strict PI gate: The plugin only displays an fmdx.org reference entry when the received PI code (or its PIreg equivalent) 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.

5.3 · PS Lock Engine and Hybrid Case

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. 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 └─▶ rdsm_freq broadcast with reset:true + stats:{pireg,piMain,...} ② First raw packet (PI=D3C3, errB[0] ≤ 1) ├─▶ freqRefs checked for D3C3 as primary PI OR as pireg │ distKm < 500 km? → threshold = 1 → confirmed immediately └─▶ 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 AI prediction broadcast → plugin panel ✓ checkAndLockPS() → PS may lock immediately ✓ Web server UI: PI + PS + country set ✓

5.5 · Dynamic vs. Static Stations

TypeDetectionv2.1 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
Changing ≥2 recurring texts in 5+ rounds No voting; last raw value displayed directly

5.6 · Alternate Frequency (AF) Decoding

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

┌─ 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 │ │ │ │ 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. │ └─────────────────────────────────────────────────────────────────┘

5.8 · Special / Wildcard PI Codes NEW in v2.1

Certain PI codes are reserved and must never be treated as real station identifiers:

PI codeMeaning
FFFFTest / wildcard – used during RDS alignment and testing
0000Invalid / not assigned

When isSpecialPI(pi) returns true, the plugin enters a pass-through mode:

5.9 · Regional PI Codes (PIreg) NEW in v2.1

Some national radio networks – for example BBC Radio in the United Kingdom or Radio 1 in Slovenia – transmit a regional PI code (pireg) instead of a single primary national PI code. The regional code uniquely identifies the transmitter within the network but differs from the primary PI code listed in most databases.

Without PIreg support, the received regional PI would fail the fmdx.org lookup, producing no reference match even though the station is well-known. Version 2.1 solves this:

fmdx.org entry for 88.3 MHz: pi = "9201" (BBC Radio 1, national primary PI) pireg = "9857" (Radio 1 Ljubljana, regional variant) ps = "Radio 1 " station = "BBC Radio 1 / Radio 1 Ljubljana" Received PI on 88.3 MHz: "9857" v2.0: findBestRefEntry("9857") → no exact PI match → null Station not identified from fmdx.org ✗ v2.1: findBestRefEntry("9857"): 1. Check primary PI match → no match 2. Check pireg match → MATCH (pireg = "9857") ✓ Returns the entry with piMain = "9201" Station correctly identified ✓ stats.pireg = "9857" stats.piMain = "9201"

5.10 · The Panel – Display Elements

┌──────────────────────────────────────────────────────┐ │ 📡 RDS AI Decoder [?] 🟢 ✕ │ ← Manual link + LED ├──────────────────────────────────────────────────────┤ │ FREQ │ 104.00 MHz │ │ PI CODE │ D3C3 │ ├──────────────────────────────────────────────────────┤ │ PS │ A n t e n n e · │ │ │ ██ ██ ██ ██ ██ ██ ██ ░░ │ ├──────────────────────────────────────────────────────┤ │ 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 │ │ AF 3/5 (60%) │ ← coverage badge (NEW) │ │ [89.5] [■94.1] [■98.0] [104.0] [107.9] │ ← freq chips (NEW) │ │ ■ blue=received □ dark=not yet seen │ ├──────────────────────────────────────────────────────┤ │ Groups:42 [RDS Follow] BER ██░░░░░░ 12% │ └──────────────────────────────────────────────────────┘
ElementMeaning
Manual link [?]Opens the online documentation at highpoint.fmdx.org/manuals/ in a new tab. Located left of the connection LED in the panel header. NEW v2.1
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 previous / currentLast fully received RadioText and live 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 – station nameFull human-readable station name from fmdx.org (or PIreg-matched entry in v2.1)
FMDX.ORG – variant chipsOne chip per known PS variant; blue at 100% position match, darker at partial match
FMDX.ORG – AF coverage badgeAF m/n (x%): m = received, n = total in DB, x% = coverage. NEW v2.1
FMDX.ORG – frequency chipsScrollable row of all fmdx.org frequencies for this PI; blue = already received live (in st.af or current frequency), dark = not yet received. Tooltip shows PS variants for that transmitter. NEW v2.1
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
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, Distance Filter and PIreg CHANGED in v2.1

After downloading the bulk transmitter data from maps.fmdx.org/api/?qth={lat},{lon}, the plugin builds two in-memory indexes. The pipeline is extended in v2.1 to index both the primary PI and the optional regional PIreg:

maps.fmdx.org JSON response │ ├─▶ extractLocations() – normalises JSON structure │ ├─▶ For each transmitter: │ • Compute Haversine distance from own location │ • Skip if distKm > FMDX_RADIUS_KM (3000 km) │ • For each station on this transmitter: │ – piUp = st.pi.toUpperCase() │ – piRegUp = st.pireg?.toUpperCase() || null ← NEW │ – entry = { pi, pireg, psVariants[], station, lat, lon, distKm } │ ├─▶ Index 1: fmdxByFreq[roundFreq(freq)] = [entry, ...] │ sorted by distKm ascending │ ├─▶ Index 2: fmdxByPI[piUp] += { freq, distKm, station, ← NEW │ psVariants, pireg } │ fmdxByPI[piRegUp] += { freq, distKm, station, ← NEW │ psVariants, pireg, │ piMain: piUp } │ (only if pireg is present and differs from pi) │ sorted by distKm ascending │ └─▶ Cache to rdsm_fmdx_cache.json { _ts, raw }

Reference matching at runtime – two-priority lookup (v2.1)

function findBestRefEntry(pi) {
  if (isSpecialPI(pi)) return null;
  const refs  = fmdxByFreq[roundFreq(currentFreq)] || [];
  const piUp  = pi.toUpperCase();

  // Priority 1: exact primary PI match on current frequency
  const exact = refs.filter(r => r.pi === piUp);
  if (exact.length === 1) return exact[0];
  if (exact.length > 1)
    return exact.sort((a,b) => (a.distKm??99999)-(b.distKm??99999))[0];

  // Priority 2: regional PI (pireg) match on current frequency
  const piRegMatches = refs.filter(r => r.pireg && r.pireg === piUp);
  if (piRegMatches.length === 1) return piRegMatches[0];
  if (piRegMatches.length > 1)
    return piRegMatches.sort((a,b) => (a.distKm??99999)-(b.distKm??99999))[0];

  return null;
}

PS-aware AF lookup (v2.1)

When the PS is locked, getAltFreqsForPIAndPS() filters the fmdxByPI list to entries whose psVariants include the confirmed PS string. This prevents showing unrelated transmitters that happen to share the same PI:

function getAltFreqsForPIAndPS(pi, confirmedPS) {
  const all = getAltFreqsForPI(pi);
  if (!confirmedPS || confirmedPS.trim().length === 0) return all;
  const norm = confirmedPS.trim().toUpperCase();
  const filtered = all.filter(item =>
    item.psVariants?.some(v => {
      const nv = v.trim().toUpperCase();
      return nv === norm || norm.startsWith(nv) || nv.startsWith(norm);
    })
  );
  return filtered.length > 0 ? filtered : all;
}

6.3 · PS Lock Engine – Priority Ladder

The PS lock is managed by checkAndLockPS(pi), called after every complete PS round (all 4 segments received). Special PIs are never locked – they bypass this function entirely.

isSpecialPI(pi)? → YES: skip entirely (pass-through mode) 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? │ │ YES → newPS = psBuf.join('') │ │ entry.psVerifiedRaw = newPS │ │ psLocked = true ────────────────────────┤ │ ▼ └─ psLocked = false (keep trying) lastBroadcastPS = newPS ────────────────────────────────────────────────────────────────── Dynamic Jump (psLocked = true, multiple variants exist): ├─ Different variant matches live buffer ≥ 75%? │ YES → lastBroadcastPS = buildHybridPS(newVariant) └─ (PS stays locked, just switches variant)

6.4 · Voting Engine – Compact Aggregation Format

A compact aggregated format is used instead of a list of individual timestamps. Votes are never cast for special PIs.

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

function applyDecay(v, now) {
  const ageMs  = now - v.lastSeen;
  const halfMs = 7 * 86400000;
  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×.

6.5 · Confidence Calculation

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

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  ·  ref-seed: amber  ·  bigram: v = 15 + conf × 30

PS source types (v2.1)

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.50 (primary PI or PIreg)0.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.1

The pipeline now handles special PIs as a separate path and uses PIreg-aware confirmation thresholds:

Frequency change │ ├─▶ clearRDSInDataHandler() piConfirmed=false, piConfirmCount=0 ├─▶ psLocked=false, lastBroadcastPS=null ├─▶ freqRefs = fmdxByFreq[freq] (entries for this freq) └─▶ broadcast rdsm_freq { reset:true, stats:{pireg,piMain,...} } First raw packet (PI=XXXX, errB[0]≤1) │ ├─▶ isSpecialPI(XXXX)? │ └─▶ YES: piConfirmed=true immediately, pass-through mode │ freqRefs = [] (no fmdx.org gate) │ onPIConfirmed() → minimal handling, no DB writes │ ├─▶ pi ≠ currentState.pi? │ └─▶ new PI candidate: piConfirmCount=1, piConfirmed=false │ psLocked=false, lastBroadcastPS=null │ psBuf/psErrBuf/psSegsSeen reset │ freqRefs checked for pi as PRIMARY PI → or as PIreg │ └─▶ threshold = getConfirmThreshold(pi): freqRefs has PI (primary or pireg) AND distKm < 500 km → 1 freqRefs has PI (primary or pireg), distKm ≥ 500 km → 2 PI not in freqRefs → 2 piConfirmCount ≥ threshold? └─▶ piConfirmed = true onPIConfirmed(pi): Seed DB from fmdx.org if no local votes yet stats.pireg set if match was via PIreg stats.piMain set if match was via PIreg broadcast rdsm_ai prediction → plugin panel ✓ dataHandler.pi/ps/rds set → web UI ✓ applyFollowToDataHandler() → if Follow active ✓ checkAndLockPS() → PS may lock immediately ✓

6.7 · Special PI Pass-Through NEW in v2.1

The isSpecialPI() guard is applied at every point where a PI code could influence persistent state:

OperationNormal PISpecial PI
Vote in votePS()Voted, stored in DBSkipped immediately
ensurePI() / db[pi]Entry created / updatedReturns null; no DB entry
checkPSDynamic()Dynamic detection activeSkipped
checkAndLockPS()Lock logic runsSkipped
AF caching via cacheAF()Stored in entry.af[]Skipped
DB save in saveDB()PersistedNever written to disk
AI prediction PS slotsFrom votes / fmdx.org / bigramDirectly from live psBuf[]
Follow mode PS outputFrom lastBroadcastPSDirectly from live psBuf[]
fmdx.org reference lookupActiveDisabled (freqRefs = [])
PI confirmation thresholdgetConfirmThreshold()Always 1
// v2.1: Special PI detection
const SPECIAL_PI_CODES = new Set(['FFFF', '0000']);
function isSpecialPI(pi) {
  return !pi || SPECIAL_PI_CODES.has(pi.toUpperCase());
}

6.8 · Database Format and Storage Management

File structure (rdsm_memory.json)

{
  "_meta": {
    "rdsFollowMode": false,
    "savedAt": 1773556231184,
    "dbVersion": 1
  },
  "D3C3": {
    "freq":           "104.00",
    "ps": { "0": { "N": {w,count,firstSeen,lastSeen}, ... }, ... },
    "psResolved":    "NRJ     ",
    "psConf":        [0.97,0.95,0.93,0,0,0,0,0],
    "psVerifiedRaw": "NRJ     ",
    "psVerifiedRawTs": 1773554677188,
    "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],
    "seen":          1773554677188,
    "seenCount":     847
  }
  // Note: special PI codes (FFFF, 0000) are NEVER stored here
}
Special PI codes are never persisted. The saveDB() function calls isSpecialPI(pi) for every key and skips any that match. This prevents test signals and invalid PI codes from polluting the learned database.

DB Version Migration

When the server starts and finds a database without _meta.dbVersion === 1, it performs a one-time automatic wipe. 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 MHzAvoid duplicates from fine tuning steps

6.9 · WebSocket Protocol CHANGED in v2.1

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, altFreqs, stats)
rdsm_freqServer → ClientFrequency change with reset:true and full stats object (including pireg/piMain)
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.1)

{
  "type":       "rdsm_ai",
  "pi":         "D3C3",
  "ps": [
    { "char": "A", "conf": 0.97, "src": "ref-match"    },
    { "char": "n", "conf": 0.95, "src": "ref-match"    },
    // ... 6 more slots
  ],
  "rt": { "text": "Dua Lipa - Levitating", "score": 0.72, "src": "raw-rt" },
  "af":         [89.5, 94.1, 98.0],
  "psName":     "Antenne Bayern",
  "psNameSrc":  "fmdx",
  "psVariants": ["Antenne ", "ANTENNE "],
  "altFreqs": [                             // NEW in v2.1: per-entry psVariants
    {
      "freq":       "89.5",
      "distKm":     88,
      "station":    "Antenne Bayern",
      "psVariants": ["Antenne "]        // NEW: variants for this transmitter
    },
    {
      "freq":       "94.1",
      "distKm":     142,
      "station":    "Antenne Bayern",
      "psVariants": ["Antenne "]
    }
  ],
  "psLocked":   true,
  "stats": {
    "freq":          "104.00",
    "seenCount":     847,
    "psVoteTotal":   312,
    "psIsDynamic":   false,
    "psLocked":      true,
    "refStation":    "Antenne Bayern",
    "refDistKm":     142,
    "refMatchScore": 95,
    "pireg":         null,        // NEW: regional PI code (if match via pireg)
    "piMain":        null         // NEW: primary PI (if match via pireg)
  },
  "ts": 1773556231184
}

For a PIreg-matched station (e.g. received PI = "9857", primary PI = "9201"):

  "stats": {
    ...
    "refStation":    "BBC Radio 1 / Radio 1 Ljubljana",
    "refDistKm":     312,
    "refMatchScore": 88,
    "pireg":         "9857",     // received PI is a regional code
    "piMain":        "9201"      // back-reference to the primary PI
  }

Example rdsm_freq payload (v2.1)

{
  "type":  "rdsm_freq",
  "freq":  "104.00",
  "reset": true,
  "pi":    null,
  "ps":    null,
  "stats": {
    "freq": "104.00", "seenCount": 0, "psVoteTotal": 0,
    "psIsDynamic": false, "psLocked": false,
    "refStation": null, "refDistKm": null, "refMatchScore": 0,
    "pireg": null,   // NEW in v2.1
    "piMain": null   // NEW in v2.1
  }
}

6.10 · dataHandler Patch – Property Locking

The native decoder receives the complete raw stream in all modes. In Follow mode, all writes to the protected fields are intercepted via JavaScript property descriptors and restored after the native decoder returns.

dh.handleData = function(wss, receivedData, rdsWss) {
  // 1. AI parsing runs first (always)
  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: () => {},
        configurable: true
      });
    });

    // 4. Run native decoder with full raw data
    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;
  }

  return orig.call(this, wss, receivedData, rdsWss);
};

Locked fields: pi, ps, ps_errors, pty, tp, ta, ms, rt0, rt1, rt0_errors, rt1_errors, rt_flag, ecc, country_iso, country_name, af.

6.11 · FMDX.ORG Panel – Frequency Chips and AF Coverage NEW in v2.1

The FMDX.ORG row in the plugin panel is extended in v2.1 with two new elements rendered by the client plugin:

AF Coverage Badge

Counts how many of the fmdx.org-listed alternative frequencies (altFreqs) have been received during the current session (present in st.af or equal to the current frequency):

// Inside renderPSName() – client plugin
const dbFreqSet  = new Set(uniqueFreqs.map(i => parseFloat(i.freq).toFixed(1)));
let matched = 0;
for (const dbFreq of dbFreqSet)
  if (receivedFreqSet.has(dbFreq)) matched++;

const pct = Math.round((matched / dbFreqSet.size) * 100);
// Displayed as: "AF 3/5 (60%)"

Scrollable Frequency Chip Row

Each known fmdx.org frequency appears as a small chip. Chips are colour-coded:

When there are more than 5 rows of chips (~25 entries), a scrollable container is shown with a maximum height of 5 rows. The scroll position is preserved across re-renders via _freqScrollTop. A custom wheel event handler prevents the page from scrolling while the chip list is being scrolled.

Each chip tooltip shows the frequency in MHz plus the known PS variants for that transmitter (from item.psVariants), e.g. "94.1 MHz – Radio 1 / RAD1".

Deduplication

Both the coverage calculation and the chip row deduplicate by frequency (rounded to 1 decimal place) before rendering, preventing duplicates when the same frequency appears in multiple fmdx.org transmitter entries.

6.12 · Bigram Predictor

The bigram predictor is a fallback of last resort. 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. In v2.1 it is also bypassed entirely for special PIs.

// Excerpt from bigram table:
BIGRAM = {
  ' ': { 'R':25, 'S':20, 'M':18, 'F':15, ... },
  'F': { 'M':40, 'R':12, ... },
  // 26 letter rows + digit rows
}
// Confidence of a bigram guess: max. ~10% – clearly a placeholder

In practice, bigram predictions are almost never visible because one of the higher-priority sources fills every slot within a few seconds of reception.

6.13 · GPS WebSocket Listener

The server plugin connects as a WebSocket client to its own /data_plugins endpoint to receive live GPS position updates:

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 (both fmdxByFreq and fmdxByPI)
      // from cached raw data with updated own location
      if (fmdxLoadedAt > 0) buildFmdxIndex(cachedRaw);
    }
  }
});

Reconnection is attempted every 15 seconds on disconnect. Starts 5 seconds after plugin initialisation. Falls back to static config.json coordinates if no GPS message is received.

6.14 · Manual Link in the Panel Header NEW in v2.1

A small documentation link is rendered in the panel header bar, immediately to the left of the connection status dot:

<!-- Inside the rdsm-hdr flex bar -->
<span style="display:flex;align-items:center;gap:5px">
  <a href="https://highpoint.fmdx.org/manuals/
           RDS-AI-Decoder-Documentation-v2.1.html"
     target="_blank"
     title="Open RDS AI Decoder Manual"
     style="color:#fff;opacity:.75;font-size:13px;
            text-decoration:none;line-height:1;
            transition:opacity .2s"
     onmouseover="this.style.opacity='1'"
     onmouseout="this.style.opacity='.75'">?</a>
  <span id="rdsm-dot" title="/data_plugins"></span>
  <button id="rdsm-close">✕</button>
</span>

The link opens in a new browser tab (target="_blank") and uses a subtle opacity transition on hover. It is styled to match the panel header's colour scheme and does not interfere with the drag behaviour of the header.

7 · REST API CHANGED in v2.1

The plugin registers one HTTP endpoint:

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

Example response for /api/rdsm/stats (v2.1)

{
  "stationCount":  312,
  "fmdxFreqCount": 4872,        // unique frequencies in fmdx.org index
  "fmdxPICount":   5140,        // NEW: unique PI + PIreg codes indexed
  "rdsFollowMode": false,
  "aiExclusiveMode": false,
  "piConfirmed":   true,
  "psLocked":      true,
  "currentPI":     "D3C3",
  "currentFreq":   "104.00"
}

The new fmdxPICount field reflects the number of unique entries in the fmdxByPI reverse index, which includes both primary PI codes and regional PIreg codes as separate entries.

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 only)
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 database
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 index
FMDX_BULK_TTL_MS6 hTime-to-live for the fmdx.org bulk cache
SPECIAL_PI_CODESFFFF, 0000PI codes that are treated as test/wildcard and never stored in the DBNEW v2.1

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