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.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.
| Feature | v2.0 | v2.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. |
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.
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 since v2.1 also 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.
When tuning to a new frequency, the following sequence runs:
| Type | Detection | v2.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 |
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:
entry.af[] (sorted, deduplicated)altFreqs list; the FMDX.ORG panel row shows an AF coverage percentage (how many of the database-listed frequencies have been received live)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 – 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:
| Element | Meaning |
|---|---|
| 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 LED | Green = WebSocket connected, Red = disconnected |
| 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 in v2.1) |
| 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. NEW v2.1 |
| FMDX.ORG – frequency chips | Scrollable 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 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 is extended in v2.1 to index 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; }
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; }
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.
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) |
The pipeline now handles special PIs as a separate path and uses PIreg-aware confirmation thresholds:
The isSpecialPI() guard is applied at every point where a PI code could influence persistent state:
| 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 | Skipped |
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[] |
| Follow mode PS output | From lastBroadcastPS | Directly from live psBuf[] |
| fmdx.org reference lookup | Active | Disabled (freqRefs = []) |
| PI confirmation threshold | getConfirmThreshold() | 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()); }
{
"_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.
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, stats) |
rdsm_freq | Server → Client | Frequency change with reset:true and full stats object (including pireg/piMain) |
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": [ // 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 }
{
"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
}
}
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.
The FMDX.ORG row in the plugin panel is extended in v2.1 with two new elements rendered by the client plugin:
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%)"
Each known fmdx.org frequency appears as a small chip. Chips are colour-coded:
var(--color-main-bright)) – frequency has been received live (in st.af or is the current tuned frequency)#1c1c1c) – frequency not yet received this sessionWhen 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".
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.
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.
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.
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.
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, // 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.
| 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) | |
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 that are treated as test/wildcard and never stored in the DB | NEW v2.1 |
RDS AI Decoder · Documentation v2.1 · Author: Highpoint · March 2026
github.com/Highpoint2000/RDS-AI-Decoder
·
Online Manual