RDS (Radio Data System) is a digital communication standard that transmits data as an inaudible signal modulated onto FM broadcasts (87.5–108 MHz). It was standardised under ETSI EN 50067 and has been in use across Europe since the 1980s.
RDS carries among others the following information:
| Code | Name | Meaning | Size |
|---|---|---|---|
| PI | Programme Identification | Unique 16-bit station identifier (hex, e.g. D3C3) | 16 bit |
| PS | Programme Service Name | Station name, max. 8 characters (e.g. "NRJ ") | 8 × 8 bit |
| RT | RadioText | Scrolling text, max. 64 characters (title, artist etc.) | 64 × 8 bit |
| PTY | Programme Type | Programme category (0–31, e.g. 10 = Pop Music) | 5 bit |
| TP | Traffic Programme | Station broadcasts traffic announcements | 1 bit |
| TA | Traffic Announcement | Traffic announcement currently active | 1 bit |
| AF | Alternate Frequencies | List of frequencies carrying the same programme | 8 bit each |
| ECC | Extended Country Code | Extended country identifier (combined with PI nibble) | 8 bit |
RDS transmits at 1,187.5 bit/s as a BPSK signal on a 57 kHz subcarrier. A complete PS transmission takes approximately 0.35 seconds under error-free conditions.
During long-distance FM reception (DX) – especially via tropospheric ducting or Sporadic-E propagation – signals are often weak and affected by multipath interference or co-channel interference. This results in:
Version 2.2 builds on the precision improvements of v2.1 with a focused user-facing enhancement: a live status indicator that communicates the decoder's confidence journey from initial reception through to a fully verified, locked PS name.
| Feature | v2.1 | v2.2 |
|---|---|---|
| Provisional → Locked status row NEW | No status row in the panel. The user had no direct visibility of the decoder's confidence state. | A dedicated STATUS row appears between PI Code and the PS character display. It cycles through three visual states: WAIT (grey, collecting data), PROVISIONAL (amber, confidence ≥ 55%, shows % and stable time in seconds), and LOCKED (green, with optional lock reason text). Rendered client-side by the new renderStatus() function. |
| New WebSocket fields: psProvisional, psProvisionalConf, psStableMs, psLockReason, psLocked NEW | rdsm_ai messages only contained psLocked (boolean) with no intermediate state. |
Every rdsm_ai message now carries five additional fields from the server:psProvisional – the current candidate PS string before lockingpsProvisionalConf – its confidence as a 0–1 floatpsStableMs – how long (ms) this candidate has been stablepsLockReason – human-readable string explaining why the PS was lockedpsLocked – boolean, true once the PS is fully locked
|
| Client state fields for provisional tracking NEW | Client st object did not track provisional state. |
Five new fields added to the client st object: psProvisional, psProvisionalConf, psStableMs, psLockReason, psLocked. These are populated from incoming rdsm_ai messages and reset on frequency change. |
| renderStatus() client function NEW | Not present. | New client function that reads the five provisional/locked state fields and updates the #rdsm-status DOM element. Called from onAI() and reset(). The LOCKED badge uses green (#44ff88), PROVISIONAL uses amber (#c8a020), WAIT uses a dark neutral tone. |
| _aiTimer promoted to module scope CHANGED | The AI broadcast debounce timer was declared locally inside functions where needed. | let _aiTimer = null; is now declared at the top of the server module. This ensures the timer handle is always accessible for clearTimeout() across all code paths (e.g. checkAndLockPS() and the dynamic jump engine), preventing duplicate deferred broadcasts. |
| CSS for status row NEW | No #rdsm-status CSS rule. |
#rdsm-status { overflow: visible; line-height: 1.8; } added to the injected stylesheet so that the badge labels and secondary text render cleanly without clipping. |
| reset() clears provisional state CHANGED | reset() did not clear provisional/locked fields (they did not exist). |
reset() now sets all five provisional/locked state fields back to their initial values and calls renderStatus() to immediately show WAIT on frequency change. |
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 this entire confidence journey visible in the panel through the new Provisional → Locked status display.
The station name (PS) consists of 8 character positions. Each position is voted on separately:
After enough votes, a reliable station name emerges. Through its database the plugin already knows thousands of stations – for recognised ones, the full name is available immediately.
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:
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.
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:
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.
Before a PS name is fully locked, the decoder goes through an intermediate provisional stage. In v2.2 this state is communicated to the user via the new STATUS row in the panel, located directly below the PI Code row.
The STATUS row cycles through three visual states:
| State | Badge colour | Condition | Additional 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%") |
The status is rendered by the client-side renderStatus() function, which reads the st.psProvisional, st.psProvisionalConf, st.psStableMs, st.psLockReason, and st.psLocked fields – all populated from the server via rdsm_ai WebSocket messages.
When tuning to a new frequency, the following sequence runs:
| Type | Detection | v2.2 behaviour |
|---|---|---|
| Static | PS name stays constant | Votes accumulated, DB grows, STATUS progresses WAIT→PROVISIONAL→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 |
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.
Certain PI codes are reserved and must never be treated as real station identifiers:
| PI code | Meaning |
|---|---|
FFFF | Test / wildcard – used during RDS alignment and testing |
0000 | Invalid / not assigned |
When isSpecialPI(pi) returns true, the plugin enters a pass-through mode:
freqRefs is set to an empty array (no fmdx.org gate applied)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).
| Element | Meaning |
|---|---|
| Manual link [?] | Opens the online documentation in a new tab. Located left of the connection LED in the panel header. |
| Connection LED | Green = WebSocket connected, Red = disconnected |
| STATUS row | Shows the PS confidence lifecycle: WAIT (grey) → PROVISIONAL (amber, % + stable time) → LOCKED (green, lock reason). NEW v2.2 |
| PS character brightness | White = high confidence; gold = fmdx.org confirmed (ref-match); amber = fmdx.org seed (ref-seed); dark = low confidence; near-black = bigram guess |
| Confidence bars | Width and opacity show the certainty of each character; gold colour for fmdx.org-sourced positions |
| RT previous / current | Last fully received RadioText and live RadioText with per-character confidence colouring |
| AF flag | Shows number of known alternate frequencies; tooltip lists all in MHz |
| ECC flag | Shows Extended Country Code hex value when received |
| FMDX.ORG – station name | Full human-readable station name from fmdx.org (or PIreg-matched entry) |
| FMDX.ORG – variant chips | One chip per known PS variant; blue at 100% position match, darker at partial match |
| FMDX.ORG – AF coverage badge | AF m/n (x%): m = received, n = total in DB, x% = coverage |
| FMDX.ORG – frequency chips | Scrollable 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 matrix | Lights up when the respective RDS group type is received |
| BER bar | Bit Error Rate: 0% = perfect, 100% = all blocks lost; green <20%, orange 20–50%, red >50% |
An RDS group consists of 4 blocks of 26 bits each (16 bits payload + 10 bits CRC checksum). The error correction code is a shortened cyclic code capable of detecting up to 5 bit errors and correcting up to 2 bit errors per block.
| Block | Content | Error level meaning |
|---|---|---|
| A | PI code (always present) | 0=clean, 1=corrected, 2=unreliable, 3=lost |
| B | Group type, version, flags | as above |
| C / C' | Group-specific | as above |
| D | Group-specific | as above |
The plugin receives these error levels as the array errB[0..3] and converts them into confidence weights:
| errLevel | Meaning | Vote weight | CONF_TABLE |
|---|---|---|---|
| 0 | Error-free | 10 | 1.00 |
| 1 | Corrected (≤2 bits) | 5 | 0.90 |
| 2 | Erroneous, unreliable | 0 (no vote) | 0.70 |
| 3 | Block lost | — (discarded) | 0.00 |
| Group | Content | Plugin usage |
|---|---|---|
| 0A | PS name (2 chars), AF (2 codes), TA, TP, MS, DI | PS voting, AF decoding, flags |
| 0B | PS name (2 chars), TA, TP, MS, DI | PS voting, flags |
| 1A | Programme Item Number, ECC, Language | ECC storage, country lookup |
| 2A | RadioText 64 chars (4 per group) | RT assembly |
| 2B | RadioText 32 chars (2 per group) | RT assembly |
| 4A | Clock time and date | not used |
| 14A/B | Enhanced Other Networks (EON) | not used |
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:
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; }
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.
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.
The server computes the provisional PS and its metadata in buildAIPrediction(). The key fields populated are:
| Field | Type | Description |
|---|---|---|
psProvisional | string | null | Best 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. |
psProvisionalConf | float 0–1 | Average confidence across all 8 character slots of the provisional candidate. |
psStableMs | integer (ms) | How long the current provisional candidate has been unchanged. Resets when the candidate string changes. |
psLockReason | string | null | Human-readable reason for the lock (e.g. "DB verified string", "FMDX match 95%", "Raw RDS fully verified"). null until locked. |
psLocked | boolean | true once checkAndLockPS() has committed a final PS string. |
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>`; } }
onAI(d) – updates all five fields from the incoming message, then calls renderStatus() and renderAll()reset() – sets psProvisional = null, psProvisionalConf = 0, psStableMs = 0, psLockReason = null, psLocked = false, then calls renderStatus()#rdsm-status { overflow: visible; line-height: 1.8; }
The overflow: visible rule ensures the coloured badge border does not get clipped by the parent row's layout, and line-height: 1.8 provides comfortable vertical spacing between the badge and the secondary text.
A compact aggregated format is used instead of a list of individual timestamps. Votes are never cast for special PIs.
// 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) }
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).
When a character already clearly dominates (weight > 2× all competitors combined), the new vote receives a multiplier of 1.5×.
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
Normal: rgb(v) where v = 20 + conf × 220 · ref-match: warm gold · ref-seed: amber · bigram: v = 15 + conf × 30
| src value | Meaning | Confidence range |
|---|---|---|
raw-0 | Received directly, error-free | 1.00 |
raw-1 | Received directly, corrected | 0.90 |
raw-2 | Received, unreliable (client only) | 0.70 |
ai-dynamic | From dynamic PS last-raw buffer | 0.90 |
ai-voted-high | From DB, conf ≥ 0.90 | 0.85–0.95 |
ai-voted-mid | From DB, conf 0.50–0.90 | 0.50–0.85 |
ai-voted-low | From DB, conf < 0.50 | 0.30–0.50 |
ref-match | fmdx.org match score ≥ 0.50 (primary PI or PIreg) | 0.50–0.90 (gold) |
ref-seed | fmdx.org initial seed, low match | 0.30–0.50 (amber) |
ai-bigram | Statistical guess, no DB entry | 0.03–0.25 |
empty | No data point available | 0 (transparent) |
| Operation | Normal PI | Special PI |
|---|---|---|
Vote in votePS() | Voted, stored in DB | Skipped immediately |
ensurePI() / db[pi] | Entry created / updated | Returns null; no DB entry |
checkPSDynamic() | Dynamic detection active | Skipped |
checkAndLockPS() | Lock logic runs, STATUS → LOCKED | Skipped; STATUS stays WAIT |
AF caching via cacheAF() | Stored in entry.af[] | Skipped |
DB save in saveDB() | Persisted | Never written to disk |
| AI prediction PS slots | From votes / fmdx.org / bigram | Directly from live psBuf[] |
| fmdx.org reference lookup | Active | Disabled (freqRefs = []) |
| psProvisional tracking | Active | Inactive (no candidate built) |
{
"_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
}
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.
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.
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.
| Rule | Value | Reason |
|---|---|---|
| Normal retention | 90 days | Cover seasonal DX propagation periods |
| Quick expiry (sparse data) | 7 days | Don't retain single-packet PI errors indefinitely |
| Vote half-life | 7 days | Adapt to station name changes |
| Vote full expiry | 30 days | Don't carry stale data |
| Maximum stations | 2000 | Limit file size |
| Save interval | 60 seconds | Reduce I/O load |
| Frequency rounding | ±0.1 MHz | Avoid duplicates from fine tuning steps |
Communication between the server plugin and the browser runs over WebSocket on the path /data_plugins. All messages are JSON.
| Message type | Direction | Meaning |
|---|---|---|
rdsm_raw | Server → Client | Raw RDS packet (PI, blocks, error levels, frequency) |
rdsm_ai | Server → Client | AI prediction (PS slots, RT, AF list, psName/Variants, altFreqs, provisional/locked fields, stats) |
rdsm_freq | Server → Client | Frequency change with reset:true and full stats object |
rdsm_rds_follow_state | Server ↔ Client | RDS Follow status (on/off) |
rdsm_set_rds_follow | Client → Server | Toggle RDS Follow (admin only) |
rdsm_get_rds_follow | Client → Server | Query current Follow status |
{
"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 "]
}
],
// ── NEW in v2.2: Provisional → Locked state fields ──────────
"psProvisional": "Antenne ", // candidate PS before lock
"psProvisionalConf": 0.87, // average slot confidence
"psStableMs": 4800, // ms this candidate has been stable
"psLockReason": "FMDX match 95%", // null until locked
"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
}
PROVISIONAL example (before locking):
"psProvisional": "NRJ ", "psProvisionalConf": 0.62, "psStableMs": 1200, "psLockReason": null, "psLocked": false // → Panel shows: [PROVISIONAL] 62% · stable 1.2s
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, ecc, country_iso, country_name, af.
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.
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%).
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.
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
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.
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.2.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.
In v2.2 the AI broadcast debounce timer is promoted to module scope:
// rds-ai-decoder_server.js – top of module let _aiTimer = null;
Previously the timer was managed locally within the functions that needed it. By declaring it at module scope, 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.
// 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);
The pattern is used in both checkAndLockPS() (on lock commit and dynamic jump) and in the main scheduleBroadcast() helper, ensuring the 80 ms batching window is always respected regardless of which code path triggers the broadcast.
The plugin registers one HTTP endpoint:
| Endpoint | Method | Description |
|---|---|---|
/api/rdsm/stats | GET | Plugin overall status: station count, fmdx.org frequency count, PI count, operating modes, current PI/freq, psLocked |
{
"stationCount": 312,
"fmdxFreqCount": 4872,
"fmdxPICount": 5140,
"rdsFollowMode": false,
"aiExclusiveMode": false,
"piConfirmed": true,
"psLocked": true,
"currentPI": "D3C3",
"currentFreq": "104.00"
}
| Constant | Value | Description | |
|---|---|---|---|
PI_CONFIRM_THRESHOLD | 2 | Default number of matching error-free packets before PI is confirmed | |
GHOST_PI_THRESHOLD | 8 | Consecutive error-free groups needed for an unrecognised PI (debug info only) | |
VOTE_HALFLIFE_DAYS | 7 | Half-life of vote weight in days | |
VOTE_EXPIRE_DAYS | 30 | Votes older than N days are fully discarded | |
STATION_EXPIRE_DAYS | 90 | Inactive stations are deleted after N days | |
QUICK_EXPIRE_DAYS | 7 | Stations with ≤2 sightings and no useful data deleted after N days | |
MAX_STATIONS | 2000 | Maximum number of stations in the database | |
CONSISTENCY_BOOST | 1.5 | Weight multiplier when dominant character is confirmed again | |
AI_BROADCAST_DELAY | 80 ms | Batching delay for AI broadcasts (prevents flooding). Timer managed by module-level _aiTimer. | CHANGED v2.2 |
DB_SAVE_INTERVAL | 60 s | Periodic database save interval | |
DB_VERSION | 1 | Triggers 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_KM | 3000 | Maximum distance of transmitters included in the fmdx.org index | |
FMDX_BULK_TTL_MS | 6 h | Time-to-live for the fmdx.org bulk cache | |
SPECIAL_PI_CODES | FFFF, 0000 | PI codes treated as test/wildcard; never stored, never locked, STATUS stays WAIT | |
PROVISIONAL_CONF_THRESHOLD | 0.55 (55%) | Minimum average slot confidence required to show PROVISIONAL badge instead of WAIT in the panel. | NEW v2.2 |
RDS AI Decoder · Documentation v2.2 · Author: Highpoint · March 2026
github.com/Highpoint2000/RDS-AI-Decoder
·
Online Manual