📡 RDS AI Decoder – Documentation v2.2a
📡

RDS AI Decoder

Plugin Documentation
Server Plugin: rds-ai-decoder_server.js
Client Plugin: rds-ai-decoder.js
Version: 2.2a
Author: Highpoint
Date: March 2026
v2.2a

Table of Contents

1What is RDS?3
2Why an AI Plugin?3
3What's New in Version 2.2a4
4Architecture – The Two Plugin Components6
5Simple Explanation – How the Plugin Works7
5.1Learning by Voting7
5.2fmdx.org Reference Database8
5.3PS Lock Engine and Hybrid Case9
5.4Provisional → Locked Status Display10
5.5Frequency Change and Pre-Cache11
5.6Dynamic vs. Static Stations12
5.7Alternate Frequency (AF) Decoding12
5.8RDS Follow Mode13
5.9Special / Wildcard PI Codes14
5.10Regional PI Codes (PIreg)14
5.11The Panel – Display Elements15
6Technical Deep Dive16
6.1RDS Group Structure and Error Correction16
6.2fmdx.org Bulk Index, Distance Filter and PIreg17
6.3PS Lock Engine – Priority Ladder19
6.4Provisional → Locked Status Engine21
6.5Three Bug Fixes in v2.2a23
6.6Voting Engine – Compact Aggregation Format27
6.7Confidence Calculation28
6.8PI Verification Pipeline29
6.9Special PI Pass-Through30
6.10Database Format and Storage Management31
6.11WebSocket Protocol33
6.12dataHandler Patch – Property Locking35
6.13FMDX.ORG Panel – Frequency Chips and AF Coverage36
6.14Bigram Predictor37
6.15GPS WebSocket Listener38
6.16Manual Link in the Panel Header38
6.17_aiTimer – Module-Level Broadcast Timer39
7REST API40
8Configuration Parameters41

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 added regional PI (PIreg) support, special PI pass-through, alternative frequency coverage display, and an in-panel manual link. Version 2.2 added a live Provisional → Locked status display in the panel and promoted the AI broadcast timer to module scope. Version 2.2a delivers three targeted bug fixes in the PS lock engine and the PI confirmation flow that were identified after the v2.2 release.

3 · What's New in Version 2.2a

Version 2.2a is a targeted bug-fix release. No new features are introduced; the three fixes below address edge-case defects in the PS lock engine and the PI confirmation handler that were identified through field testing after the v2.2 release.

FixLocationProblem in v2.2Solution in v2.2a
Bug 1 – psIsDynamic false positive FIX onPIConfirmed() When a station was previously classified as dynamic (e.g. scrolling PS or multiple recorded variants in the DB) but fmdx.org now shows exactly one static PS variant, the psIsDynamic flag was never cleared. This caused the PS lock engine to skip all voting and always display the "last raw" character buffer instead of locking to the known static name. Stations that briefly produced a spurious second variant (e.g. during a weak DX episode) remained permanently stuck in dynamic mode even after better reception confirmed a single static name. At the start of onPIConfirmed(), after the fmdx.org reference entry is retrieved, a guard clause now checks:

if (entry.psIsDynamic && ref?.psVariants?.length === 1)

When both conditions are true, the entry is reset to static mode: psIsDynamic = false, the vote table (entry.ps) is cleared, psResolved and psConf are reset, and dbDirty is set so the correction is persisted. The lock engine then proceeds normally and can lock the station to the confirmed single static variant.
Bug 2 – psVerifiedRaw not stored after fmdx.org match FIX checkAndLockPS() When the live buffer was verified by a full raw round (allRawVerified = true) and a fmdx.org reference match also existed, the psVerifiedRaw field was only written when there was no reference match (else if (!ref?.psVariants?.length)). This meant that for well-known stations with an fmdx.org entry, a perfectly clean raw reception round never stored the verified string in the database. On the next session the plugin had to re-derive the PS from votes or the reference rather than using the cached ground truth. The condition was changed from else if (!ref?.psVariants?.length) to a plain else block. The psVerifiedRaw write is now executed regardless of whether a reference entry exists, subject to the existing sanity checks (isPSStringClean() and optional reference variant match). This ensures that a clean raw reception is always preserved as the authoritative cached string for future sessions.
Bug 3 – psVerifiedRaw not used when reference also matches FIX checkAndLockPS() – Priority A branch Priority A of the lock engine checks whether a previously stored psVerifiedRaw exists. In v2.2 the inner branch was structured as:

if (ref?.psVariants?.length > 0) { … }
else if (!ref?.psVariants?.length) { newPS = entry.psVerifiedRaw; }

The else if condition is logically equivalent to the negation of the if condition, which means the else if branch was unreachable whenever a reference existed. For stations that had both a stored psVerifiedRaw and a fmdx.org entry, Priority A could produce newPS = null when refMatchIsGood was false, causing the lock to fall through to Priority B or C unnecessarily.
The else if (!ref?.psVariants?.length) was replaced with a plain else. When Priority A is reached and ref?.psVariants?.length > 0 is true but refMatchIsGood is false (low match score), the fallback now correctly assigns newPS = entry.psVerifiedRaw instead of leaving newPS = null. This ensures the stored verified string is always used as the lock source when it is available, regardless of the current fmdx.org match score.
All three fixes are server-side only (in rds-ai-decoder_server.js). The client plugin rds-ai-decoder.js is unchanged in v2.2a beyond the version string bump to '2.2a'. No database migration is required; the fixes take effect immediately on the next PI confirmation event after server restart.

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.2a │ │ │ │ │ │ │ │ • fmdx.org Bulk Index (3000 km) │ │ │ │ – primary PI + PIreg indexed │ │ │ │ – fmdxByFreq + fmdxByPI │ │ │ │ • Special PI pass-through (FFFF/0000)│ │ │ │ • Voting Engine + PS Lock │ │ │ │ • Provisional→Locked state engine │ │ │ │ • _aiTimer at module scope │ │ │ │ • 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 │ │ │ │ │ │ │ │ BUG FIXES v2.2a: │ │ ← NEW v2.2a │ │ • Bug 1: psIsDynamic reset on │ │ │ │ single-variant fmdx ref │ │ │ │ • Bug 2: psVerifiedRaw stored even │ │ │ │ when fmdx ref exists │ │ │ │ • Bug 3: else (not else if) in │ │ │ │ Priority-A lock branch │ │ │ └──────────────┬────────────────────────┘ │ │ │ │ │ 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.2a (version bump) │ │ • Panel + manual │ │ link (header) │ │ • STATUS row: │ │ WAIT→PROV→LOCKED │ │ • renderStatus() │ │ • 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 extended this with regional PI codes and special PI pass-through. Version 2.2 makes the confidence journey visible in the panel through the new Provisional → Locked status display. Version 2.2a corrects three subtle defects in the lock engine that could prevent correct locking in specific edge cases.

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 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) v2.2a fix: the fallback to psVerifiedRaw now also triggers when a fmdx.org ref exists but match score is below 75% (changed "else if (!ref)" → "else") 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 v2.2a fix: psVerifiedRaw is now stored even when a fmdx.org ref exists (changed "else if (!ref)" → "else") ───────────────────────────────────────────────────────── 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 · Provisional → Locked Status Display

Before a PS name is fully locked, the decoder goes through an intermediate provisional stage. This state is communicated to the user via the STATUS row in the panel, located directly below the PI Code row.

The STATUS row cycles through three visual states:

StateBadge colourConditionAdditional info shown
WAIT Dark grey (neutral) Default on start / after frequency change. Not enough data yet. "collecting…"
PROVISIONAL Amber (#c8a020) A candidate PS exists with confidence ≥ 55% but is not yet locked. Confidence % · stable time in seconds (e.g. "72% · stable 3.4s")
LOCKED Green (#44ff88) psLocked = true received from server. Optional lock reason (e.g. "– DB verified string", "– FMDX match 95%")
v2.2a impact on STATUS: The three bug fixes mean that stations with a single fmdx.org variant that were incorrectly stuck in dynamic mode will now progress from PROVISIONAL to LOCKED correctly. Previously these stations would remain at WAIT/PROVISIONAL indefinitely because the lock engine was bypassed by the psIsDynamic flag.

5.5 · 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 ├─▶ psProvisional/Conf/StableMs/LockReason reset ├─▶ 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): [v2.2a] psIsDynamic reset if ref has 1 variant fmdx.org seed applied to DB AI prediction broadcast → plugin panel ✓ (STATUS→PROVISIONAL) checkAndLockPS() → PS may lock immediately ✓ (STATUS→LOCKED) Web server UI: PI + PS + country set ✓

5.6 · Dynamic vs. Static Stations

TypeDetectionv2.2a behaviour
Static PS name stays constant Votes accumulated, DB grows, STATUS progresses WAIT→PROVISIONAL→LOCKED
Static (previously misclassified) DB had psIsDynamic=true but fmdx.org lists exactly 1 variant FIX v2.2a psIsDynamic is now reset at PI confirmation. Votes cleared. Station re-enters normal static lock flow. STATUS progresses to LOCKED.
Multi-variant fmdx.org lists 2+ PS variants Dynamic jump engine active; locked PS switches to best-matching variant. STATUS stays LOCKED.
Scrolling PS rotates (A→AB→B→BC…) No voting; last raw value displayed; STATUS stays WAIT / PROVISIONAL
Changing ≥2 recurring texts in 5+ rounds No voting; last raw value displayed directly

5.7 · 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 stored in the DB, displayed in the AF flag, forwarded to the dataHandler in Follow mode, and cross-referenced against the fmdx.org altFreqs list for the coverage badge in the FMDX.ORG panel row.

5.8 · 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.9 · Special / Wildcard PI Codes

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.10 · Regional PI Codes (PIreg)

Some national radio networks transmit a regional PI code (pireg) instead of a single primary national PI code. Without PIreg support, the received regional PI would fail the fmdx.org lookup. The plugin's two-priority lookup handles this transparently: primary PI is checked first, then pireg. When a match is found via pireg, the stats object carries both pireg (the received code) and piMain (the primary PI back-reference).

5.11 · The Panel – Display Elements

┌──────────────────────────────────────────────────────┐ │ 📡 RDS AI Decoder [?] 🟢 ✕ │ ← Manual link + LED ├──────────────────────────────────────────────────────┤ │ FREQ │ 104.00 MHz │ │ PI CODE │ D3C3 │ │ STATUS │ [LOCKED] – FMDX match 95% │ ├──────────────────────────────────────────────────────┤ │ 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 │ │ │ [Antenne ] [antenne ] [ANTENNE ] │ │ │ AF 3/5 (60%) │ │ │ [89.5] [■94.1] [■98.0] [104.0] [107.9] │ ├──────────────────────────────────────────────────────┤ │ Groups:42 [RDS Follow] BER ██░░░░░░ 12% │ └──────────────────────────────────────────────────────┘
ElementMeaning
Manual link [?]Opens the online documentation in a new tab. Located left of the connection LED in the panel header.
Connection LEDGreen = WebSocket connected, Red = disconnected
STATUS rowShows the PS confidence lifecycle: WAIT (grey) → PROVISIONAL (amber, % + stable time) → LOCKED (green, lock reason). Reaches LOCKED faster and more reliably in v2.2a due to the three bug fixes.
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)
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
FMDX.ORG – frequency chipsScrollable row of all fmdx.org frequencies for this PI; blue = already received live, dark = not yet received. Tooltip shows PS variants for that transmitter.
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

After downloading the bulk transmitter data from maps.fmdx.org/api/?qth={lat},{lon}, the plugin builds two in-memory indexes. The pipeline indexes 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 │ – 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, │ psVariants, pireg } │ fmdxByPI[piRegUp] += { freq, distKm, station, │ 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

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

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 → if ref?.psVariants?.length > 0: │ if refMatchIsGood (≥0.80) → hybrid with best variant │ else → newPS = psVerifiedRaw ←── v2.2a Bug 3 fix │ else (no ref): │ newPS = psVerifiedRaw ←── was: else if (unreachable) │ 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 ◀── v2.2a Bug 2 fix │ (stored even when fmdx.org ref exists) │ │ psLocked = true ────────────────────────┤ │ ▼ └─ psLocked = false (keep trying) lastBroadcastPS = newPS STATUS row → LOCKED ✓ ────────────────────────────────────────────────────────────────── 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 · Provisional → Locked Status Engine

Before a PS is fully locked, the server tracks a provisional candidate – the best current guess with its associated confidence and how long it has been stable. This state is broadcast in every rdsm_ai message and rendered by the client as the STATUS row.

Server-side: building the provisional state

The server computes the provisional PS and its metadata in buildAIPrediction(). The key fields populated are:

FieldTypeDescription
psProvisionalstring | nullBest candidate PS string (the resolved DB string, or the best fmdx.org variant, or the live raw buffer if clean enough). null if no viable candidate yet.
psProvisionalConffloat 0–1Average confidence across all 8 character slots of the provisional candidate.
psStableMsinteger (ms)How long the current provisional candidate has been unchanged. Resets when the candidate string changes.
psLockReasonstring | nullHuman-readable reason for the lock (e.g. "DB verified string", "FMDX match 95%", "Raw RDS fully verified"). null until locked.
psLockedbooleantrue once checkAndLockPS() has committed a final PS string.

Client-side: renderStatus()

function renderStatus() {
  const el = document.getElementById('rdsm-status');
  if (!el) return;

  if (st.psLocked) {
    const reason = st.psLockReason ? ` – ${st.psLockReason}` : '';
    el.innerHTML = `
      <span class="rf on" style="background:#1b3b2a;color:#44ff88;
        border:1px solid #44ff88;padding:3px 7px;line-height:1.6;">LOCKED</span>
      <span style="color:#777;font-size:11px;">${reason}</span>`;
    return;
  }

  const confPct = Math.round((st.psProvisionalConf || 0) * 100);
  if (st.psProvisional && confPct >= 55) {
    const stableS = (st.psStableMs || 0) / 1000;
    el.innerHTML = `
      <span class="rf on" style="background:#2a2331;color:#c8a020;
        border:1px solid #c8a020;padding:3px 7px;line-height:1.6;">PROVISIONAL</span>
      <span style="color:#888;font-size:11px;">
        ${confPct}% · stable ${stableS.toFixed(1)}s</span>`;
  } else {
    el.innerHTML = `
      <span class="rf" style="border:1px solid #2a2a2a;">WAIT</span>
      <span style="color:#555;font-size:11px;">collecting…</span>`;
  }
}

Integration points

CSS

#rdsm-status { overflow: visible; line-height: 1.8; }

6.5 · Three Bug Fixes in v2.2a FIX

This section provides the full technical context for each of the three bug fixes introduced in v2.2a.

Bug 1 – psIsDynamic false positive reset in onPIConfirmed()

Root cause

The psIsDynamic flag is set to true by checkPSDynamic() when either a scrolling pattern or at least two recurring different PS strings are detected over time. Once set, this flag is sticky: it is only cleared on a frequency change or plugin restart. There was no code path that re-evaluated the flag against fresh fmdx.org evidence at PI confirmation time.

This created a feedback loop: a station that briefly exhibited a scrolling PS during a poor DX episode (or whose DB entry was polluted by a co-channel interference event on the same frequency) was permanently treated as dynamic, bypassing all voting and lock logic.

Fix

At the beginning of onPIConfirmed(pi), immediately after retrieving the fmdx.org reference entry, the following guard clause was added:

// Bug 1 fix: reset psIsDynamic if fmdx.org has exactly one static variant
if (entry.psIsDynamic && ref?.psVariants?.length === 1) {
    entry.psIsDynamic = false;
    entry.ps          = {};
    entry.psResolved  = null;
    entry.psConf      = new Array(8).fill(0);
    dbDirty = true;
    if (DEBUG) logInfo(`[${PLUGIN_NAME}] PI ${pi}: psIsDynamic reset (fmdx ref has 1 static variant)`);
}

Conditions for reset

ConditionMeaning
entry.psIsDynamic === trueStation was previously classified as dynamic
ref?.psVariants?.length === 1fmdx.org lists exactly one PS variant → strong evidence the station is static; the earlier classification was erroneous

Both conditions must hold simultaneously. If fmdx.org lists 0 variants (unknown station) or 2+ variants (genuinely multi-variant), the flag is not touched.

Effect

After the reset, the vote table is empty and the entry behaves like a freshly seen station. The fmdx.org seed is then applied (as for any new PI), and the lock engine proceeds normally. On subsequent receptions the station will accumulate votes and reach LOCKED state via Priority A, B, or C as appropriate.

Before v2.2a fix: Session 1 (weak DX, co-channel): PI=D3C3 → psDynamicBuf gets "NRJ ", "NRJ ", "ENERGY " detectChangingPS → psIsDynamic = true ← set Session 2 (good signal, same station): PI=D3C3 → onPIConfirmed() entry.psIsDynamic = true ← never cleared lock engine skipped STATUS stays WAIT/PROVISIONAL forever ───────────────────────────────────────────────────── After v2.2a fix: Session 2 (good signal, same station): PI=D3C3 → onPIConfirmed() ref.psVariants = ["NRJ "] (1 variant) entry.psIsDynamic reset to false ← NEW entry.ps = {}, psResolved = null fmdx.org seed applied lock engine runs → STATUS → LOCKED ✓

Bug 2 – psVerifiedRaw not stored when fmdx.org ref exists

Root cause

Inside checkAndLockPS(), the block that writes entry.psVerifiedRaw when a full raw verification round succeeds (allRawVerified = true) was guarded by:

// v2.2 (buggy):
if (allRawVerified) {
    const candidate = currentState.psBuf.join('');
    if (isPSStringClean(candidate)) {
        const nu = candidate.trim().toUpperCase();
        const ok = !ref?.psVariants?.length ||
            ref.psVariants.some(v => ...);
        if (ok && !entry.psVerifiedRaw) {
            entry.psVerifiedRaw   = candidate;
            entry.psVerifiedRawTs = Date.now();
            dbDirty = true;
        }
    }
}
// Only reached if !newPS (i.e. Priority A and B both produced null)
if (!newPS && allRawVerified) {
    // set newPS from psBuf
}

The outer write block (the one that stores psVerifiedRaw in the DB) was placed inside a branch that was only reached when Priority A (psVerifiedRaw already exists) and Priority B (fmdx.org match ≥ 75%) both failed to produce a newPS. Because Priority B succeeds for any well-known station on a clean reception day, the outer write block was never entered for those stations – a clean raw round was never cached.

Fix

The psVerifiedRaw write block was lifted out of the conditional ladder and placed unconditionally within the allRawVerified check, using a plain else instead of else if (!ref?.psVariants?.length):

// v2.2a (fixed): psVerifiedRaw is always stored when allRawVerified,
// regardless of whether a fmdx.org reference exists.
if (allRawVerified) {
    const candidate = currentState.psBuf.join('');
    if (isPSStringClean(candidate)) {
        const nu = candidate.trim().toUpperCase();
        const ok = !ref?.psVariants?.length ||
            ref.psVariants.some(v =>
                v.trim().toUpperCase() === nu ||
                v.trim().toUpperCase().startsWith(nu) ||
                nu.startsWith(v.trim().toUpperCase()));
        if (ok && !entry.psVerifiedRaw) {
            entry.psVerifiedRaw   = candidate;
            entry.psVerifiedRawTs = Date.now();
            dbDirty = true;
        }
    }
}

Effect

After this fix, the first error-free complete PS round for any station – including those with a fmdx.org reference – is stored in psVerifiedRaw. On subsequent sessions the lock engine can use Priority A immediately instead of having to rediscover the PS via votes or reference matching. This accelerates locking and makes the plugin more robust on frequencies where fmdx.org match scores are borderline.

Bug 3 – Priority A fallback unreachable due to else if logic error

Root cause

In the Priority A branch of checkAndLockPS(), the code was structured as:

// v2.2 (buggy):
if (entry.psVerifiedRaw && isPSStringClean(entry.psVerifiedRaw) &&
    entry.psVerifiedRaw.trim().length > 0) {

    if (ref?.psVariants?.length > 0) {
        if (refMatchIsGood && bestScore >= 0.8)
            newPS = buildHybridPS(bestVariant);
        else
            newPS = entry.psVerifiedRaw;   // ← inner else: correct
    } else if (!ref?.psVariants?.length) {
        newPS = entry.psVerifiedRaw;      // ← outer else if: UNREACHABLE
                                          //   (!A === !A is tautology of outer if)
    }
    if (newPS) lockReason = 'DB verified string';
}

The else if (!ref?.psVariants?.length) is the logical complement of if (ref?.psVariants?.length > 0). These two branches are mutually exclusive and exhaustive – the outer else if could never be reached because its condition is identical to the negation of the preceding if condition. In JavaScript, when the outer if is false (no variants), the else if is true, but since both expressions evaluate to the same boolean, the branch is equivalent to a plain else. However, because the inner else (inside if (ref?.psVariants?.length > 0)) already assigns newPS = entry.psVerifiedRaw when refMatchIsGood is false, the outer else if only matters for the case where there is no reference at all – and there it is indeed reachable.

The actual defect is subtler: when a reference exists but refMatchIsGood is false, the inner else correctly sets newPS = entry.psVerifiedRaw. But when a reference exists and refMatchIsGood is true but bestScore < 0.8, the inner if is false and the inner else sets newPS = entry.psVerifiedRaw – this is actually correct. The real issue revealed by code review is that the structure was confusing and the outer else if was redundant noise that could mask future refactoring errors.

The fix simplifies the structure to a plain else, making the intent unambiguous:

// v2.2a (fixed):
if (entry.psVerifiedRaw && isPSStringClean(entry.psVerifiedRaw) &&
    entry.psVerifiedRaw.trim().length > 0) {

    if (ref?.psVariants?.length > 0) {
        if (refMatchIsGood && bestScore >= 0.8)
            newPS = buildHybridPS(bestVariant);
        else
            newPS = entry.psVerifiedRaw;
    } else {                              // ← plain else (was: else if)
        newPS = entry.psVerifiedRaw;
    }
    if (newPS) lockReason = 'DB verified string';
}

Effect

The simplified structure ensures that whenever Priority A is reached and entry.psVerifiedRaw is present and clean, newPS will always be set to either a hybrid PS (when fmdx.org match is strong) or the raw verified string (all other cases). No code path within Priority A can now produce newPS = null.

Combined impact of all three fixes

Scenario: Station with PI=D3C3 seen during DX session 6 months ago. DB has: psIsDynamic=true (from co-channel event), no psVerifiedRaw. fmdx.org: one variant ["NRJ "]. v2.2 behaviour: onPIConfirmed() → psIsDynamic=true → voting skipped checkAndLockPS() → guard: isSpecialPI? no. psLocked? no. entry.psIsDynamic? true → dynamic path no lock → STATUS stays PROVISIONAL/WAIT v2.2a behaviour: onPIConfirmed(): ref.psVariants.length === 1 → psIsDynamic reset ← Bug 1 fix fmdx.org seed applied to empty vote table checkAndLockPS(): Priority B: fmdx match score ≥ 0.75 on first full round? → newPS = "NRJ " (hybrid) → psLocked = true → STATUS → LOCKED ✓ Priority C (if first round clean): → psVerifiedRaw = "NRJ " stored ← Bug 2 fix Next session: Priority A used immediately ← Bug 3 fix ensures the fallback is always reachable

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

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.8 · PI Verification Pipeline

Frequency change │ ├─▶ clearRDSInDataHandler() piConfirmed=false, piConfirmCount=0 ├─▶ psLocked=false, lastBroadcastPS=null ├─▶ psProvisional/Conf/StableMs/LockReason = null/0/0/null ├─▶ freqRefs = fmdxByFreq[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 = [] │ STATUS row stays WAIT (no locking) │ ├─▶ 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) piConfirmCount ≥ threshold? └─▶ piConfirmed = true onPIConfirmed(pi): [v2.2a Bug 1] psIsDynamic reset if ref.variants.length===1 Seed DB from fmdx.org if no local votes yet broadcast rdsm_ai → STATUS row: PROVISIONAL ✓ checkAndLockPS() → STATUS row: LOCKED if ready ✓ [v2.2a Bug 2] psVerifiedRaw stored unconditionally [v2.2a Bug 3] Priority A fallback always reachable dataHandler updated (if Follow active)

6.9 · Special PI Pass-Through

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 runs, STATUS → LOCKEDSkipped; STATUS stays WAIT
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[]
fmdx.org reference lookupActiveDisabled (freqRefs = [])
psProvisional trackingActiveInactive (no candidate built)

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

psVerifiedRaw integrity check

On load and before every save, isPSStringClean() validates any stored psVerifiedRaw. Strings containing characters outside printable ASCII (0x20–0x7E) or extended Latin (0xA0–0xFF) are discarded. This prevents corrupt blocks from a previous errLevel-2 decode session from poisoning future sessions.

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.11 · 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, RT, AF list, psName/Variants, altFreqs, provisional/locked fields, 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.2a)

{
  "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": [
    {
      "freq":       "89.5",
      "distKm":     88,
      "station":    "Antenne Bayern",
      "psVariants": ["Antenne "]
    }
  ],
  "psProvisional":     "Antenne ",
  "psProvisionalConf": 0.87,
  "psStableMs":        4800,
  "psLockReason":      "FMDX match 95%",
  "psLocked":          true,
  "stats": {
    "freq":          "104.00",
    "seenCount":     847,
    "psVoteTotal":   312,
    "psIsDynamic":   false,
    "psLocked":      true,
    "refStation":    "Antenne Bayern",
    "refDistKm":     142,
    "refMatchScore": 95,
    "pireg":         null,
    "piMain":        null
  },
  "ts": 1773556231184
}

6.12 · 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) {
    applyFollowToDataHandler();

    const locked = {};
    fields.forEach(f => {
      locked[f] = dh.dataToSend[f];
      Object.defineProperty(dh.dataToSend, f, {
        get: () => locked[f],
        set: () => {},
        configurable: true
      });
    });

    const result = orig.call(this, wss, receivedData, rdsWss);

    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, 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.13 · FMDX.ORG Panel – Frequency Chips and AF Coverage

The FMDX.ORG row shows the station name, variant chips, an AF coverage badge, and a scrollable chip row of all known fmdx.org frequencies for this PI.

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). Displayed as: AF m/n (x%).

Scrollable Frequency Chip Row

Each known fmdx.org frequency appears as a chip. Chips are colour-coded: blue when received live, dark when not yet received. When more than ~25 entries are present, a scrollable container is shown (max 5 rows visible). The scroll position is preserved across re-renders via _freqScrollTop. Each chip tooltip shows the frequency in MHz plus the known PS variants for that transmitter.

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

6.15 · 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)
      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.16 · Manual Link in the Panel Header

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 -->
<a id="rdsm-manual-link"
   href="https://highpoint.fmdx.org/manuals/RDS-AI-Decoder-Documentation-v2.2a.html"
   target="_blank"
   title="Open RDS AI Decoder Manual">?</a>

The link opens in a new browser tab and is styled to match the panel header colour scheme. The drag handler explicitly excludes this element to prevent conflicts with header dragging.

6.17 · _aiTimer – Module-Level Broadcast Timer

In v2.2 the AI broadcast debounce timer was promoted to module scope:

// rds-ai-decoder_server.js – top of module
let _aiTimer = null;

All code paths that schedule or cancel a deferred rdsm_ai broadcast share a single handle. This prevents a race condition where two concurrent paths (e.g. checkAndLockPS() and the dynamic jump engine) could each schedule their own setTimeout, resulting in duplicate or out-of-order broadcasts.

Usage pattern

// Cancel any pending broadcast before scheduling a new one
if (_aiTimer) { clearTimeout(_aiTimer); _aiTimer = null; }
_aiTimer = setTimeout(() => {
    _aiTimer = null;
    broadcast({ type: 'rdsm_ai', ...prediction, ts: Date.now() });
    if (rdsFollowMode) applyFollowToDataHandler();
}, AI_BROADCAST_DELAY);

7 · REST API

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

{
  "stationCount":  312,
  "fmdxFreqCount": 4872,
  "fmdxPICount":   5140,
  "rdsFollowMode": false,
  "aiExclusiveMode": false,
  "piConfirmed":   true,
  "psLocked":      true,
  "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 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. Timer managed by module-level _aiTimer.
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 treated as test/wildcard; never stored, never locked, STATUS stays WAIT
PROVISIONAL_MIN_CONF0.55 (55%)Minimum average slot confidence required to show PROVISIONAL badge instead of WAIT in the panel
FMDX_REINDEX_MIN_DIST_KM100 kmMinimum GPS displacement before the fmdx.org index is rebuilt from cache

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