From 10e82d3de8fdc9450d0fe8895e852b67e44ebeb3 Mon Sep 17 00:00:00 2001 From: jLynx Date: Mon, 16 Mar 2026 20:44:01 +1300 Subject: [PATCH 1/5] feat(rds): Implement RDS decoding functionality - Added RDS methods for toggling the panel, handling messages, clearing data, and exporting logs in `rds.ts`. - Updated application state to include RDS properties in `state.ts`. - Enhanced DSP worker to process RDS signals and extract relevant data in `dsp-worker.ts`. - Introduced RDS panel in the UI with controls for clearing and exporting data in `index.html`. - Styled RDS components in `style.css` for better user experience. - Implemented RDS decoder logic in a new `rds.ts` file within the worker directory. - Updated backend to manage RDS decoder instances and handle RDS-related parameters in `backend.ts`. - Integrated RDS decoding into the RX stream processing in `rx-stream.ts`. - Updated types to include RDS message structure and decoder state in `types.ts`. --- src/client/app/connection.ts | 3 +- src/client/app/main.ts | 2 + src/client/app/rds.ts | 61 ++++ src/client/app/state.ts | 13 + src/client/dsp-worker.ts | 38 ++ src/client/index.html | 43 ++- src/client/style.css | 63 ++++ src/client/worker/backend.ts | 9 +- src/client/worker/rds.ts | 525 ++++++++++++++++++++++++++++ src/client/worker/remote-clients.ts | 1 + src/client/worker/rx-stream.ts | 26 +- src/client/worker/types.ts | 14 + 12 files changed, 791 insertions(+), 7 deletions(-) create mode 100644 src/client/app/rds.ts create mode 100644 src/client/worker/rds.ts diff --git a/src/client/app/connection.ts b/src/client/app/connection.ts index 77c7c93..9c180f8 100644 --- a/src/client/app/connection.ts +++ b/src/client/app/connection.ts @@ -205,7 +205,8 @@ export const connectionMethods = { Comlink.proxy((spectrumData: any) => this.drawSpectrum(spectrumData)), Comlink.proxy((audioSamples: any) => this.playAudio(audioSamples)), Comlink.proxy((vfoIndex: number, freq: number, samples: any) => this._feedWhisperVfo(vfoIndex, freq, samples)), - Comlink.proxy((vfoIndex: number, freq: number, msg: any) => this._onPocsagMessage(vfoIndex, freq, msg)) + Comlink.proxy((vfoIndex: number, freq: number, msg: any) => this._onPocsagMessage(vfoIndex, freq, msg)), + Comlink.proxy((vfoIndex: number, freq: number, msg: any) => this._onRdsMessage(vfoIndex, freq, msg)) ); } catch (e: any) { console.error('Error starting RX stream:', e); diff --git a/src/client/app/main.ts b/src/client/app/main.ts index 0666eb2..938818a 100644 --- a/src/client/app/main.ts +++ b/src/client/app/main.ts @@ -11,6 +11,7 @@ import { settingsMethods } from './settings'; import { bookmarkMethods } from './bookmarks'; import { whisperMethods } from './whisper'; import { pocsagMethods } from './pocsag'; +import { rdsMethods } from './rds'; import { zoomMethods } from './zoom'; import { remoteMethods } from './remote'; @@ -36,6 +37,7 @@ createApp({ ...bookmarkMethods, ...whisperMethods, ...pocsagMethods, + ...rdsMethods, ...zoomMethods, ...remoteMethods, }, diff --git a/src/client/app/rds.ts b/src/client/app/rds.ts new file mode 100644 index 0000000..4c5375b --- /dev/null +++ b/src/client/app/rds.ts @@ -0,0 +1,61 @@ +import type { AppInstance } from './types'; + +export const rdsMethods = { + toggleRdsPanel(this: AppInstance) { + this.rds.panelOpen = !this.rds.panelOpen; + }, + _onRdsMessage(this: AppInstance, vfoIndex: number, freqMhz: number, msg: any) { + const time = new Date().toLocaleTimeString(); + const freq = freqMhz ? this.formatFreq(freqMhz) + ' MHz' : ''; + + if (msg.pi !== undefined) { + this.rds.pi = msg.pi; + this.rds.freq = freq; + this.rds.vfoIndex = vfoIndex; + } + if (msg.ps !== undefined) { + this.rds.ps = msg.ps; + this.rds.freq = freq; + this.rds.vfoIndex = vfoIndex; + this.rds.log.push({ time, field: 'PS', value: msg.ps, freq, vfoIndex }); + } + if (msg.rt !== undefined) { + this.rds.rt = msg.rt; + this.rds.log.push({ time, field: 'RT', value: msg.rt, freq, vfoIndex }); + } + if (msg.pty !== undefined) { + this.rds.pty = msg.pty; + this.rds.ptyLabel = msg.ptyLabel || ''; + } + if (msg.tp !== undefined) this.rds.tp = msg.tp; + if (msg.ta !== undefined) this.rds.ta = msg.ta; + + // Auto-scroll log + this.$nextTick(() => { + const el = this.$refs.rdsBody; + if (el) el.scrollTop = el.scrollHeight; + }); + }, + clearRds(this: AppInstance) { + this.rds.log = []; + this.rds.ps = ''; + this.rds.rt = ''; + this.rds.pi = ''; + this.rds.pty = 0; + this.rds.ptyLabel = ''; + this.rds.tp = false; + this.rds.ta = false; + }, + exportRds(this: AppInstance) { + const lines = this.rds.log.map((e: any) => + `[${e.time}] ${e.freq} ${e.field}: ${e.value}` + ); + const blob = new Blob([lines.join('\n')], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `rds-${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.txt`; + a.click(); + URL.revokeObjectURL(url); + }, +}; diff --git a/src/client/app/state.ts b/src/client/app/state.ts index 83e55cc..9f98e9b 100644 --- a/src/client/app/state.ts +++ b/src/client/app/state.ts @@ -74,6 +74,19 @@ export function createAppData() { panelOpen: false, log: [] as Array<{ time: string; freq: string; vfoIndex: number; capcode: string; type: string; text: string; baud: number }>, }, + rds: { + panelOpen: false, + ps: '', + rt: '', + pi: '', + pty: 0, + ptyLabel: '', + tp: false, + ta: false, + freq: '', + vfoIndex: null as number | null, + log: [] as Array<{ time: string; field: string; value: string; freq: string; vfoIndex: number }>, + }, bookmarkCategories: BOOKMARK_CATEGORIES, bookmarkCategoryFilter: '', bookmarkSearch: '', diff --git a/src/client/dsp-worker.ts b/src/client/dsp-worker.ts index 6cf6d3a..9030644 100644 --- a/src/client/dsp-worker.ts +++ b/src/client/dsp-worker.ts @@ -8,6 +8,8 @@ let ddc: any; let vfoState: any; let sharedIqPtr = 0; let sharedSabViews: Int8Array[] | null = null; +let rdsDdc: any = null; +let rdsPrevPhase = 0; const IF_RATES: Record = { nfm: 50000, @@ -112,6 +114,29 @@ self.onmessage = async (e: MessageEvent) => { } else { self.postMessage({ type: "audio", samples: null, chunkId: msg.chunkId, squelchOpen: vfoState.squelchOpen, dspTime: dspTime }); } + + // RDS MPX extraction via second DspProcessor + if (rdsDdc && msg.params.rds && msg.params.mode === 'wfm') { + const chunkLen = msg.useSab ? msg.chunkLen : (msg.chunk ? msg.chunk.byteLength : 0); + if (chunkLen > 0) { + const iqPtr = rdsDdc.process_iq_only_ptr(sharedIqPtr, chunkLen); + const iqLen = rdsDdc.get_iq_output_len(); + if (iqLen > 0) { + const iqView = new Float32Array(_wasm.memory.buffer, iqPtr, iqLen); + const numSamples = iqLen / 2; + const mpxOut = new Float32Array(numSamples); + for (let i = 0; i < numSamples; i++) { + const ph = Math.atan2(iqView[i * 2 + 1], iqView[i * 2]); + let diff = ph - rdsPrevPhase; + if (diff > Math.PI) diff -= 2 * Math.PI; + else if (diff < -Math.PI) diff += 2 * Math.PI; + mpxOut[i] = diff; + rdsPrevPhase = ph; + } + (self as any).postMessage({ type: "rds_mpx", mpx: mpxOut.buffer }, [mpxOut.buffer]); + } + } + } } catch (err: any) { self.postMessage({ type: "error", error: err.message }); } @@ -137,6 +162,19 @@ function configureDDC(params: any, systemCenterFreq: number): void { // Apply UI audio filters (High Pass 300Hz, Low Pass BW/2) ddc.set_audio_filters(params.lowPass || false, params.highPass || false); + + // RDS: second DspProcessor for MPX extraction (250kHz I/Q) + if (params.rds && params.mode === 'wfm') { + if (!rdsDdc) { + rdsDdc = new DspProcessor(systemSampleRate, 0.0, 200000); + rdsDdc.set_if_sample_rate(250000); + rdsPrevPhase = 0; // Only reset on initial creation, not every configure + } + rdsDdc.set_shift(systemSampleRate, offsetFreq); + } else if (rdsDdc) { + rdsDdc.free(); + rdsDdc = null; + } } function processVfoAudio(chunkLenBytes: number, params: any): Float32Array | null { diff --git a/src/client/index.html b/src/client/index.html index b430282..89e7fd0 100644 --- a/src/client/index.html +++ b/src/client/index.html @@ -84,6 +84,9 @@ + @@ -290,7 +293,7 @@
@@ -587,6 +590,44 @@
+ +
+
+
+ + FM RDS Decoder + {{ rds.ps || rds.pi }} +
+
+ + +
+
+
+ {{ rds.ps }} + PI:{{ rds.pi }} + {{ rds.ptyLabel }} + + TP + TA + +
+
{{ rds.rt }}
+
+
+ Enable "Decode RDS" on a WFM VFO and tune to an FM station to see station data. +
+
+ + {{ entry.time }} + {{ entry.freq }} + {{ entry.field }} + {{ entry.value }} +
+
+
+
diff --git a/src/client/style.css b/src/client/style.css index db49086..2b7142c 100644 --- a/src/client/style.css +++ b/src/client/style.css @@ -1143,6 +1143,69 @@ canvas { font-style: italic; } +/* ────── RDS ────── */ +.rds-station { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 12px; + background: #1a1a2e; + border-bottom: 1px solid var(--border); + font-family: 'Roboto Mono', monospace; +} + +.rds-ps { + font-size: 18px; + font-weight: 700; + color: #4fc3f7; + letter-spacing: 2px; +} + +.rds-pi { + font-size: 11px; + color: #80cbc4; +} + +.rds-pty { + font-size: 11px; + color: #ce93d8; + background: #4a148c; + padding: 1px 6px; + border-radius: 3px; +} + +.rds-flags { + display: flex; + gap: 4px; +} + +.rds-flag { + font-size: 9px; + font-weight: 700; + padding: 1px 4px; + border-radius: 3px; + background: #1b5e20; + color: #a5d6a7; + letter-spacing: 0.5px; +} + +.rds-ta { + background: #b71c1c; + color: #ef9a9a; +} + +.rds-rt { + padding: 6px 12px; + background: #111; + border-bottom: 1px solid var(--border); + font-family: 'Roboto Mono', monospace; + font-size: 12px; + color: #e0e0e0; + letter-spacing: 0.5px; + white-space: pre-wrap; + word-break: break-word; +} + /* ────── Bookmarks ────── */ .bookmark-panel-header { /* inherits standard panel-header styles only */ diff --git a/src/client/worker/backend.ts b/src/client/worker/backend.ts index d1a466e..85071c8 100644 --- a/src/client/worker/backend.ts +++ b/src/client/worker/backend.ts @@ -161,8 +161,8 @@ export class Backend { initRemoteClient = initRemoteClient.bind(this); feedRemoteAudioChunk = feedRemoteAudioChunk.bind(this); - async startRxStream(opts: RxStreamOpts, spectrumCallback: any, audioCallback: any, whisperCallback: any = null, pocsagCallback: any = null): Promise { - return startRxStream(this, opts, spectrumCallback, audioCallback, whisperCallback, pocsagCallback); + async startRxStream(opts: RxStreamOpts, spectrumCallback: any, audioCallback: any, whisperCallback: any = null, pocsagCallback: any = null, rdsCallback: any = null): Promise { + return startRxStream(this, opts, spectrumCallback, audioCallback, whisperCallback, pocsagCallback, rdsCallback); } getDspStats(): any { @@ -195,13 +195,16 @@ export class Backend { if (params.pocsag === false && this.vfoStates && this.vfoStates[index]) { this.vfoStates[index].pocsagDecoder = null; } + if (params.rds === false && this.vfoStates && this.vfoStates[index]) { + this.vfoStates[index].rdsDecoder = null; + } } addVfo(): number { if (!this.vfoParams) return -1; const centerFreq = this._centerFreq || 100.0; const bw = 150000; - const params: VfoParams = { freq: centerFreq, mode: 'wfm', enabled: false, deEmphasis: '50us', squelchEnabled: false, squelchLevel: -100.0, lowPass: true, highPass: false, bandwidth: bw, volume: 50, pocsag: false }; + const params: VfoParams = { freq: centerFreq, mode: 'wfm', enabled: false, deEmphasis: '50us', squelchEnabled: false, squelchLevel: -100.0, lowPass: true, highPass: false, bandwidth: bw, volume: 50, pocsag: false, rds: false, rdsRegion: 'eu' }; this.vfoParams.push(params); const index = this.vfoParams.length - 1; diff --git a/src/client/worker/rds.ts b/src/client/worker/rds.ts new file mode 100644 index 0000000..0c1c24c --- /dev/null +++ b/src/client/worker/rds.ts @@ -0,0 +1,525 @@ +/* +Copyright (c) 2026, jLynx + +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + Neither the name of Great Scott Gadgets nor the names of its contributors may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +*/ + +import type { RDSMessage } from './types'; + +// ── RDS Constants ──────────────────────────────────────────────── +const RDS_BITRATE = 1187.5; +const RDS_SUBCARRIER = 57000; + +// Offset words for each block position (10-bit, per IEC 62106) +const OFFSET_A = 0x0FC; +const OFFSET_B = 0x198; +const OFFSET_C = 0x168; +const OFFSET_CP = 0x350; +const OFFSET_D = 0x1B4; + +// Generator polynomial for RDS checkword (x^10 + x^8 + x^7 + x^5 + x^4 + x^3 + 1) +const RDS_POLY = 0x5B9; // 10-bit: 10110111001 +const SYNDROME_A = calcSyndrome(OFFSET_A); +const SYNDROME_B = calcSyndrome(OFFSET_B); +const SYNDROME_C = calcSyndrome(OFFSET_C); +const SYNDROME_CP = calcSyndrome(OFFSET_CP); +const SYNDROME_D = calcSyndrome(OFFSET_D); + +function calcSyndrome(offset: number): number { + let reg = 0; + for (let i = 25; i >= 0; i--) { + const bit = (i >= 10) ? 0 : ((offset >> i) & 1); + const fb = ((reg >> 9) & 1) ^ bit; + reg = ((reg << 1) & 0x3FF); + if (fb) reg ^= RDS_POLY; + } + return reg; +} + +function computeSyndrome(block: number): number { + let reg = 0; + for (let i = 25; i >= 0; i--) { + const fb = ((reg >> 9) & 1) ^ ((block >> i) & 1); + reg = ((reg << 1) & 0x3FF); + if (fb) reg ^= RDS_POLY; + } + return reg; +} + +// EU PTY labels (0-31) +const PTY_LABELS_EU = [ + 'None', 'News', 'Current Affairs', 'Information', 'Sport', 'Education', + 'Drama', 'Culture', 'Science', 'Varied', 'Pop Music', 'Rock Music', + 'Easy Listening', 'Light Classical', 'Serious Classical', 'Other Music', + 'Weather', 'Finance', 'Children', 'Social Affairs', 'Religion', + 'Phone In', 'Travel', 'Leisure', 'Jazz Music', 'Country Music', + 'National Music', 'Oldies Music', 'Folk Music', 'Documentary', + 'Alarm Test', 'Alarm' +]; + +// NA (RBDS) PTY labels +const PTY_LABELS_NA = [ + 'None', 'News', 'Information', 'Sports', 'Talk', 'Rock', + 'Classic Rock', 'Adult Hits', 'Soft Rock', 'Top 40', 'Country', + 'Oldies', 'Soft', 'Nostalgia', 'Jazz', 'Classical', + 'R&B', 'Soft R&B', 'Language', 'Religious Music', 'Religious Talk', + 'Personality', 'Public', 'College', 'Spanish Talk', 'Spanish Music', + 'Hip Hop', '', '', 'Weather', 'Emergency Test', 'Emergency' +]; + +// ── Biquad Filter Section ──────────────────────────────────────── +// Direct Form II Transposed biquad for numerical stability + +class BiquadSection { + private b0: number; + private b1: number; + private b2: number; + private a1: number; + private a2: number; + private x1 = 0; + private x2 = 0; + private y1 = 0; + private y2 = 0; + + constructor(b0: number, b1: number, b2: number, a1: number, a2: number) { + this.b0 = b0; this.b1 = b1; this.b2 = b2; + this.a1 = a1; this.a2 = a2; + } + + process(x: number): number { + const y = this.b0 * x + this.b1 * this.x1 + this.b2 * this.x2 + - this.a1 * this.y1 - this.a2 * this.y2; + this.x2 = this.x1; this.x1 = x; + this.y2 = this.y1; this.y1 = y; + return y; + } + + reset(): void { + this.x1 = 0; this.x2 = 0; + this.y1 = 0; this.y2 = 0; + } +} + +/** Create a 4th-order Butterworth low-pass filter (two cascaded biquads). + * Provides 24 dB/octave rolloff — much better than single-pole IIR (6 dB/oct). */ +function makeButterworthLpf4(cutoffHz: number, sampleRate: number): BiquadSection[] { + // Q values for 4th-order Butterworth (from poles at ±22.5° and ±67.5°) + const Qs = [0.54119610, 1.30656296]; + return Qs.map(Q => { + const w0 = 2 * Math.PI * cutoffHz / sampleRate; + const sinW0 = Math.sin(w0); + const cosW0 = Math.cos(w0); + const alpha = sinW0 / (2 * Q); + const a0 = 1 + alpha; + return new BiquadSection( + (1 - cosW0) / 2 / a0, // b0 + (1 - cosW0) / a0, // b1 + (1 - cosW0) / 2 / a0, // b2 + -2 * cosW0 / a0, // a1 + (1 - alpha) / a0 // a2 + ); + }); +} + +// ── RDS Decoder ────────────────────────────────────────────────── + +export class RDSDecoder { + private callback: (msg: RDSMessage) => void; + private region: string; + + // 57 kHz carrier + private carrierPhase: number = 0; + private carrierPhaseInc: number; + + // 4th-order Butterworth LPF for I and Q channels (replaces single-pole IIR) + private bqI: BiquadSection[]; + private bqQ: BiquadSection[]; + + // Costas loop for carrier recovery (eliminates carrier phase dependency) + private loopAlpha: number; // proportional gain + private loopBeta: number; // integral gain + private loopIntegrator: number = 0; + + // Clock recovery (1187.5 bps) + private samplesPerBit: number; + private clockPhase: number = 0; + private prevBpskI: number = 0; + + // Differential decode using complex conjugate product (carrier-independent) + private prevSymI: number = 0; + private prevSymQ: number = 0; + + // Block assembly + private shiftReg: number = 0; + private bitCount: number = 0; + + // Sync state machine + private synced: boolean = false; + private blockIndex: number = 0; // 0=A, 1=B, 2=C, 3=D + private goodBlocks: number = 0; + private blocks: number[] = [0, 0, 0, 0]; // data words (16 bits each) + private blockValid: boolean[] = [false, false, false, false]; // per-block CRC validity + private blockErrors: number = 0; + + // Decoded RDS data + private pi: number = 0; + private pty: number = -1; + private tp: boolean = false; + private ta: boolean = false; + private psChars: (number | null)[] = new Array(8).fill(null); + private psConfirm: (number | null)[] = new Array(8).fill(null); // confirm-on-second-reception + private rtChars: (number | null)[] = new Array(64).fill(null); + private rtConfirm: (number | null)[] = new Array(64).fill(null); + private rtAbFlag: number = -1; + private lastPs: string = ''; + private lastRt: string = ''; + + constructor(sampleRate: number, callback: (msg: RDSMessage) => void, region: string = 'eu') { + this.callback = callback; + this.region = region; + + this.carrierPhaseInc = 2 * Math.PI * RDS_SUBCARRIER / sampleRate; + this.samplesPerBit = sampleRate / RDS_BITRATE; + + // 4th-order Butterworth LPF at 2.4 kHz + // At 19 kHz (nearest interferer after 57 kHz mixing): ~72 dB rejection + // vs. single-pole IIR which only gave ~18 dB + this.bqI = makeButterworthLpf4(2400, sampleRate); + this.bqQ = makeButterworthLpf4(2400, sampleRate); + + // Costas loop: 2nd-order PLL with ~50 Hz natural frequency + // Converges in ~20 ms, tracks carrier phase without affecting data + const loopBW = 50; + const wn = 2 * Math.PI * loopBW / sampleRate; + const zeta = 0.707; // critically damped + this.loopAlpha = 2 * zeta * wn; + this.loopBeta = wn * wn; + } + + process(samples: Float32Array): void { + for (let i = 0; i < samples.length; i++) { + const sample = samples[i]; + + // ── Mix with 57 kHz carrier (phase includes Costas correction) ── + const cosVal = Math.cos(this.carrierPhase); + const sinVal = Math.sin(this.carrierPhase); + this.carrierPhase += this.carrierPhaseInc; + + const rawI = sample * cosVal; + const rawQ = sample * sinVal; + + // ── 4th-order Butterworth LPF on I and Q ── + let filtI = rawI; + for (let k = 0; k < this.bqI.length; k++) filtI = this.bqI[k].process(filtI); + let filtQ = rawQ; + for (let k = 0; k < this.bqQ.length; k++) filtQ = this.bqQ[k].process(filtQ); + + // ── Costas loop: carrier phase recovery ── + // Error signal: I×Q / (I²+Q²) — normalized, stable at φ=0 and φ=π + // Both lock points are valid for BPSK (differential decode handles π ambiguity) + const power = filtI * filtI + filtQ * filtQ; + if (power > 1e-12) { + const loopError = (filtI * filtQ) / power; + this.loopIntegrator += loopError * this.loopBeta; + // Clamp integrator to prevent wind-up (no freq offset in digital system) + if (this.loopIntegrator > 0.01) this.loopIntegrator = 0.01; + else if (this.loopIntegrator < -0.01) this.loopIntegrator = -0.01; + this.carrierPhase -= loopError * this.loopAlpha + this.loopIntegrator; + } + + // Wrap carrier phase + if (this.carrierPhase > 2 * Math.PI) this.carrierPhase -= 2 * Math.PI; + else if (this.carrierPhase < 0) this.carrierPhase += 2 * Math.PI; + + // ── Clock recovery ── + // Zero-crossing PLL on I component (now aligned by Costas loop) + this.clockPhase += 1.0; + if ((filtI > 0) !== (this.prevBpskI > 0)) { + const error = this.clockPhase - this.samplesPerBit / 2; + this.clockPhase -= error * 0.1; + } + this.prevBpskI = filtI; + + // ── Sample at bit boundary ── + if (this.clockPhase >= this.samplesPerBit) { + this.clockPhase -= this.samplesPerBit; + + // Differential decode using complex conjugate product: + // diffProd = Re{z[n] × conj(z[n-1])} = I·Iprev + Q·Qprev + // Positive → same phase (bit 0), Negative → π phase change (bit 1) + // This works regardless of carrier phase offset! + const diffProd = filtI * this.prevSymI + filtQ * this.prevSymQ; + const decodedBit = diffProd < 0 ? 1 : 0; + this.prevSymI = filtI; + this.prevSymQ = filtQ; + + this.processBit(decodedBit); + } + } + } + + private processBit(bit: number): void { + // Shift bit into 26-bit register + this.shiftReg = ((this.shiftReg << 1) | bit) & 0x3FFFFFF; + this.bitCount++; + + if (!this.synced) { + // Try to find sync by checking syndrome against all offset words + if (this.bitCount >= 26) { + const syn = computeSyndrome(this.shiftReg); + if (syn === SYNDROME_A) { + this.synced = true; + this.goodBlocks = 1; + this.blocks[0] = (this.shiftReg >> 10) & 0xFFFF; + this.blockIndex = 1; + this.bitCount = 0; + } else if (syn === SYNDROME_B) { + this.synced = true; + this.goodBlocks = 0; + this.blocks[1] = (this.shiftReg >> 10) & 0xFFFF; + this.blockIndex = 2; + this.bitCount = 0; + } else if (syn === SYNDROME_C || syn === SYNDROME_CP) { + this.synced = true; + this.goodBlocks = 0; + this.blocks[2] = (this.shiftReg >> 10) & 0xFFFF; + this.blockIndex = 3; + this.bitCount = 0; + } else if (syn === SYNDROME_D) { + this.synced = true; + this.goodBlocks = 0; + this.blocks[3] = (this.shiftReg >> 10) & 0xFFFF; + this.blockIndex = 0; + this.bitCount = 0; + } + } + return; + } + + // Synced: wait for 26 bits per block + if (this.bitCount < 26) return; + this.bitCount = 0; + + const syn = computeSyndrome(this.shiftReg); + const expectedSyndromes = [SYNDROME_A, SYNDROME_B, SYNDROME_C, SYNDROME_D]; + // Block C can also be C' (for type B groups) + const expectedSyn = expectedSyndromes[this.blockIndex]; + const isValid = (syn === expectedSyn) || + (this.blockIndex === 2 && syn === SYNDROME_CP); + + if (isValid) { + this.blocks[this.blockIndex] = (this.shiftReg >> 10) & 0xFFFF; + this.blockValid[this.blockIndex] = true; + this.goodBlocks++; + this.blockErrors = 0; + } else { + this.blockValid[this.blockIndex] = false; + this.blockErrors++; + if (this.blockErrors > 10) { + // Lost sync + this.synced = false; + this.goodBlocks = 0; + this.blockErrors = 0; + return; + } + } + + // Advance block position + this.blockIndex = (this.blockIndex + 1) & 3; + + // After block D (index wraps to 0), decode the group + if (this.blockIndex === 0 && this.goodBlocks >= 2) { + this.decodeGroup(); + } + if (this.blockIndex === 0) { + this.goodBlocks = 0; + this.blockValid.fill(false); + } + } + + /** Accept a character only after it has been received identically twice + * at the same position (standard RDS error-rejection technique). */ + private confirmChar(pos: number, char: number, chars: (number | null)[], confirm: (number | null)[]): void { + if (char < 0x20 || char >= 0x7F) return; + if (confirm[pos] === char) { + chars[pos] = char; // confirmed — accept + } + confirm[pos] = char; + } + + private decodeGroup(): void { + const bv = this.blockValid; + + // Block A: PI code — only if block A passed CRC + if (bv[0]) { + const pi = this.blocks[0]; + if (pi !== this.pi && pi !== 0) { + this.pi = pi; + const piHex = pi.toString(16).toUpperCase().padStart(4, '0'); + this.callback({ pi: piHex }); + } + } + + // Block B must be valid — it contains group type, PTY, segment index + if (!bv[1]) return; + const blockB = this.blocks[1]; + + const groupType = (blockB >> 12) & 0xF; + const groupVersion = (blockB >> 11) & 1; // 0=A, 1=B + const tp = ((blockB >> 10) & 1) === 1; + const pty = (blockB >> 5) & 0x1F; + + // TP flag + if (tp !== this.tp) { + this.tp = tp; + this.callback({ tp }); + } + + // PTY + if (pty !== this.pty) { + this.pty = pty; + const labels = this.region === 'na' ? PTY_LABELS_NA : PTY_LABELS_EU; + this.callback({ pty, ptyLabel: labels[pty] || '' }); + } + + // Group 0: Basic tuning and PS name — need block D valid + if (groupType === 0 && bv[3]) { + const ta = (blockB & 0x10) !== 0; + if (ta !== this.ta) { + this.ta = ta; + this.callback({ ta }); + } + + // PS name: 2 chars per group 0, segment from bits 1:0 of block B + const segment = blockB & 0x03; + const blockD = this.blocks[3]; + const c1 = (blockD >> 8) & 0xFF; + const c2 = blockD & 0xFF; + + this.confirmChar(segment * 2, c1, this.psChars, this.psConfirm); + this.confirmChar(segment * 2 + 1, c2, this.psChars, this.psConfirm); + + // Check if PS is complete (all 8 chars received) + const ps = this.buildPS(); + if (ps && ps !== this.lastPs) { + this.lastPs = ps; + this.callback({ ps }); + } + } + + // Group 2: RadioText + if (groupType === 2) { + const abFlag = (blockB >> 4) & 1; + + // A/B flag change means new RT message — clear buffer + if (this.rtAbFlag !== -1 && abFlag !== this.rtAbFlag) { + this.rtChars.fill(null); + this.rtConfirm.fill(null); + } + this.rtAbFlag = abFlag; + + const segment = blockB & 0x0F; + + if (groupVersion === 0 && bv[2] && bv[3]) { + // 2A: 4 chars per segment (from blocks C and D) + const blockC = this.blocks[2]; + const blockD = this.blocks[3]; + const c1 = (blockC >> 8) & 0xFF; + const c2 = blockC & 0xFF; + const c3 = (blockD >> 8) & 0xFF; + const c4 = blockD & 0xFF; + const base = segment * 4; + this.confirmChar(base, c1, this.rtChars, this.rtConfirm); + this.confirmChar(base + 1, c2, this.rtChars, this.rtConfirm); + this.confirmChar(base + 2, c3, this.rtChars, this.rtConfirm); + this.confirmChar(base + 3, c4, this.rtChars, this.rtConfirm); + + // Check for end-of-message marker (0x0D) + if (c1 === 0x0D || c2 === 0x0D || c3 === 0x0D || c4 === 0x0D) { + const rt = this.buildRT(); + if (rt && rt !== this.lastRt) { + this.lastRt = rt; + this.callback({ rt }); + } + } + } else if (groupVersion === 1 && bv[3]) { + // 2B: 2 chars per segment (from block D only) + const blockD = this.blocks[3]; + const c1 = (blockD >> 8) & 0xFF; + const c2 = blockD & 0xFF; + const base = segment * 2; + this.confirmChar(base, c1, this.rtChars, this.rtConfirm); + this.confirmChar(base + 1, c2, this.rtChars, this.rtConfirm); + } + + // Periodically emit partial RT + const rt = this.buildRT(); + if (rt && rt.length >= 4 && rt !== this.lastRt) { + this.lastRt = rt; + this.callback({ rt }); + } + } + } + + private buildPS(): string | null { + if (this.psChars.some(c => c === null)) return null; + return String.fromCharCode(...(this.psChars as number[])); + } + + private buildRT(): string | null { + let end = 0; + for (let i = 0; i < 64; i++) { + if (this.rtChars[i] !== null) end = i + 1; + } + if (end === 0) return null; + + let s = ''; + for (let i = 0; i < end; i++) { + s += this.rtChars[i] !== null ? String.fromCharCode(this.rtChars[i]!) : ' '; + } + return s.trimEnd(); + } + + reset(): void { + this.synced = false; + this.blockIndex = 0; + this.goodBlocks = 0; + this.blockErrors = 0; + this.blockValid.fill(false); + this.bitCount = 0; + this.shiftReg = 0; + this.pi = 0; + this.pty = -1; + this.tp = false; + this.ta = false; + this.psChars.fill(null); + this.psConfirm.fill(null); + this.rtChars.fill(null); + this.rtConfirm.fill(null); + this.rtAbFlag = -1; + this.lastPs = ''; + this.lastRt = ''; + this.carrierPhase = 0; + this.clockPhase = 0; + this.prevBpskI = 0; + this.prevSymI = 0; + this.prevSymQ = 0; + this.loopIntegrator = 0; + for (const bq of this.bqI) bq.reset(); + for (const bq of this.bqQ) bq.reset(); + } +} diff --git a/src/client/worker/remote-clients.ts b/src/client/worker/remote-clients.ts index 70cbae3..a73a959 100644 --- a/src/client/worker/remote-clients.ts +++ b/src/client/worker/remote-clients.ts @@ -67,6 +67,7 @@ export function _getOrCreateClientState(this: Backend, clientId: string): Remote audioQueues: [], mixBuf: null, pocsagDecoders: [], + rdsDecoders: [], squelchOpen: [] }); } diff --git a/src/client/worker/rx-stream.ts b/src/client/worker/rx-stream.ts index 1e883b6..6a2bdc6 100644 --- a/src/client/worker/rx-stream.ts +++ b/src/client/worker/rx-stream.ts @@ -22,6 +22,7 @@ import * as Comlink from 'comlink'; import { FFT } from './wasm-init'; import { RationalResampler } from './dsp-pipeline'; import { POCSAGDecoder } from './pocsag'; +import { RDSDecoder } from './rds'; import type { RxStreamOpts, VfoParams, VfoState, PerfCounters } from './types'; import { IF_RATES, AUDIO_RATE } from './types'; import type { Backend } from './backend'; @@ -32,7 +33,8 @@ export async function startRxStream( spectrumCallback: any, audioCallback: any, whisperCallback: any, - pocsagCallback: any + pocsagCallback: any, + rdsCallback: any ): Promise { backend._remoteClientAudioCb = audioCallback; // Save reference for when chunk arrives backend._remoteClientWhisperCb = whisperCallback; // Save for remote client transcription @@ -83,7 +85,7 @@ export async function startRxStream( if (backend.ddcs) backend.ddcs.forEach((d: any) => { try { d.free(); } catch (_) { } }); // Initialize dynamic VFO arrays (start with one VFO) - const defaultVfoParams: VfoParams = { freq: centerFreq, mode: 'wfm', enabled: false, deEmphasis: '50us', squelchEnabled: false, squelchLevel: -100.0, lowPass: true, highPass: false, bandwidth: initialBandwidth, volume: 50, pocsag: false }; + const defaultVfoParams: VfoParams = { freq: centerFreq, mode: 'wfm', enabled: false, deEmphasis: '50us', squelchEnabled: false, squelchLevel: -100.0, lowPass: true, highPass: false, bandwidth: initialBandwidth, volume: 50, pocsag: false, rds: false, rdsRegion: 'eu' }; backend.vfoParams = [{ ...defaultVfoParams }]; const MAX_USB_SAMPLES = 131072; @@ -104,6 +106,7 @@ export async function startRxStream( const makeVfoState = (): VfoState => ({ squelchOpen: false, pocsagDecoder: null, + rdsDecoder: null, audioQueue: new Float32Array(32768), audioQueueLen: 0, }); @@ -116,6 +119,8 @@ export async function startRxStream( const msg = e.data; if (msg.type === "audio") { backend._handleWorkerAudio!(index, msg); + } else if (msg.type === "rds_mpx") { + handleRdsMpx(index, msg); } else if (msg.type === "error") { console.error(`[DSP Worker ${index}] Error:`, msg.error); } @@ -487,6 +492,18 @@ export async function startRxStream( } }; + const handleRdsMpx = (v: number, msg: any): void => { + const state = backend.vfoStates![v]; + const params = backend.vfoParams![v]; + if (!state || !params || !params.rds || params.mode !== 'wfm') return; + if (!state.rdsDecoder) { + state.rdsDecoder = new RDSDecoder(250000, (rmsg: any) => { + if (rdsCallback) rdsCallback(v, params.freq, rmsg); + }, params.rdsRegion || 'eu'); + } + state.rdsDecoder.process(new Float32Array(msg.mpx)); + }; + let chunkCounter = 0; const handleWorkerAudio = (v: number, msg: any): void => { @@ -532,6 +549,11 @@ export async function startRxStream( } else if (!params.pocsag && state.pocsagDecoder) { state.pocsagDecoder = null; } + + // Clean up RDS decoder when disabled + if ((!params.rds || params.mode !== 'wfm') && state.rdsDecoder) { + state.rdsDecoder = null; + } } // Mixer block logic diff --git a/src/client/worker/types.ts b/src/client/worker/types.ts index aeeeb8f..e618625 100644 --- a/src/client/worker/types.ts +++ b/src/client/worker/types.ts @@ -30,11 +30,14 @@ export interface VfoParams { bandwidth: number; volume: number; pocsag: boolean; + rds: boolean; + rdsRegion: string; } export interface VfoState { squelchOpen: boolean; pocsagDecoder: any; + rdsDecoder: any; audioQueue: Float32Array; audioQueueLen: number; lastMode?: string; @@ -95,9 +98,20 @@ export interface RemoteClientState { audioQueues: { queue: Float32Array; len: number }[]; mixBuf: Float32Array | null; pocsagDecoders: any[]; + rdsDecoders: any[]; squelchOpen: boolean[]; } +export interface RDSMessage { + ps?: string; + rt?: string; + pi?: string; + pty?: number; + ptyLabel?: string; + tp?: boolean; + ta?: boolean; +} + export interface POCSAGMessage { capcode: number; func: number; From 2265dc691eb9d60cbc6dc516f3a2c69990098336 Mon Sep 17 00:00:00 2001 From: jLynx Date: Mon, 16 Mar 2026 20:55:18 +1300 Subject: [PATCH 2/5] feat(rds): Update RDS processing with 19 kHz pilot PLL and enhanced filtering --- src/client/dsp-worker.ts | 2 +- src/client/worker/rds.ts | 219 +++++++++++++++++++++++++-------------- 2 files changed, 141 insertions(+), 80 deletions(-) diff --git a/src/client/dsp-worker.ts b/src/client/dsp-worker.ts index 9030644..286bc7e 100644 --- a/src/client/dsp-worker.ts +++ b/src/client/dsp-worker.ts @@ -166,7 +166,7 @@ function configureDDC(params: any, systemCenterFreq: number): void { // RDS: second DspProcessor for MPX extraction (250kHz I/Q) if (params.rds && params.mode === 'wfm') { if (!rdsDdc) { - rdsDdc = new DspProcessor(systemSampleRate, 0.0, 200000); + rdsDdc = new DspProcessor(systemSampleRate, 0.0, 250000); rdsDdc.set_if_sample_rate(250000); rdsPrevPhase = 0; // Only reset on initial creation, not every configure } diff --git a/src/client/worker/rds.ts b/src/client/worker/rds.ts index 0c1c24c..2021634 100644 --- a/src/client/worker/rds.ts +++ b/src/client/worker/rds.ts @@ -22,7 +22,7 @@ import type { RDSMessage } from './types'; // ── RDS Constants ──────────────────────────────────────────────── const RDS_BITRATE = 1187.5; -const RDS_SUBCARRIER = 57000; +const RDS_PILOT = 19000; // stereo pilot — RDS subcarrier = 3 × pilot // Offset words for each block position (10-bit, per IEC 62106) const OFFSET_A = 0x0FC; @@ -114,6 +114,22 @@ class BiquadSection { } } +/** Create a bandpass biquad filter at the given center frequency and Q. */ +function makeBandpass(centerHz: number, Q: number, sampleRate: number): BiquadSection { + const w0 = 2 * Math.PI * centerHz / sampleRate; + const sinW0 = Math.sin(w0); + const cosW0 = Math.cos(w0); + const alpha = sinW0 / (2 * Q); + const a0 = 1 + alpha; + return new BiquadSection( + alpha / a0, // b0 + 0, // b1 + -alpha / a0, // b2 + -2 * cosW0 / a0, // a1 + (1 - alpha) / a0 // a2 + ); +} + /** Create a 4th-order Butterworth low-pass filter (two cascaded biquads). * Provides 24 dB/octave rolloff — much better than single-pole IIR (6 dB/oct). */ function makeButterworthLpf4(cutoffHz: number, sampleRate: number): BiquadSection[] { @@ -141,28 +157,43 @@ export class RDSDecoder { private callback: (msg: RDSMessage) => void; private region: string; - // 57 kHz carrier - private carrierPhase: number = 0; - private carrierPhaseInc: number; + // 19 kHz pilot PLL — BPF + quadrature mixing + ultra-narrow LPF + private pilotPhase: number = 0; + private pilotFreq: number; // nominal 2π×19000/fs + private pilotAlpha: number; // PLL proportional gain + private pilotBpf: BiquadSection; // 19 kHz bandpass filter + private pilotMixI: number = 0; // mixed pilot baseband I (after narrow LPF) + private pilotMixQ: number = 0; // mixed pilot baseband Q (after narrow LPF) + private pilotMixAlpha: number; // ultra-narrow LPF coefficient - // 4th-order Butterworth LPF for I and Q channels (replaces single-pole IIR) + // 4th-order Butterworth LPF for RDS I and Q channels private bqI: BiquadSection[]; private bqQ: BiquadSection[]; - // Costas loop for carrier recovery (eliminates carrier phase dependency) - private loopAlpha: number; // proportional gain - private loopBeta: number; // integral gain - private loopIntegrator: number = 0; - // Clock recovery (1187.5 bps) private samplesPerBit: number; private clockPhase: number = 0; private prevBpskI: number = 0; - // Differential decode using complex conjugate product (carrier-independent) + // Differential decode using complex conjugate product private prevSymI: number = 0; private prevSymQ: number = 0; + // Debug stats (logged every 5 seconds) + private dbgLastLog: number = 0; + private dbgBitsTotal: number = 0; + private dbgBlocksTotal: number = 0; + private dbgBlocksValid: number = 0; + private dbgGroupsDecoded: number = 0; + private dbgSyncLost: number = 0; + private dbgBitMagSum: number = 0; // sum of |filtI| at bit boundaries + private dbgBitDiffSum: number = 0; // sum of |diffProd| at bit boundaries + private dbgBitSamples: number = 0; + private dbgPilotIsum: number = 0; // sum of |pilotLpfI| + private dbgPilotQsum: number = 0; // sum of |pilotLpfQ| + private dbgPilotIQcount: number = 0; + private dbgClockCorr: number = 0; // clock correction events + // Block assembly private shiftReg: number = 0; private bitCount: number = 0; @@ -181,9 +212,7 @@ export class RDSDecoder { private tp: boolean = false; private ta: boolean = false; private psChars: (number | null)[] = new Array(8).fill(null); - private psConfirm: (number | null)[] = new Array(8).fill(null); // confirm-on-second-reception private rtChars: (number | null)[] = new Array(64).fill(null); - private rtConfirm: (number | null)[] = new Array(64).fill(null); private rtAbFlag: number = -1; private lastPs: string = ''; private lastRt: string = ''; @@ -191,36 +220,57 @@ export class RDSDecoder { constructor(sampleRate: number, callback: (msg: RDSMessage) => void, region: string = 'eu') { this.callback = callback; this.region = region; - - this.carrierPhaseInc = 2 * Math.PI * RDS_SUBCARRIER / sampleRate; this.samplesPerBit = sampleRate / RDS_BITRATE; - // 4th-order Butterworth LPF at 2.4 kHz - // At 19 kHz (nearest interferer after 57 kHz mixing): ~72 dB rejection - // vs. single-pole IIR which only gave ~18 dB - this.bqI = makeButterworthLpf4(2400, sampleRate); - this.bqQ = makeButterworthLpf4(2400, sampleRate); - - // Costas loop: 2nd-order PLL with ~50 Hz natural frequency - // Converges in ~20 ms, tracks carrier phase without affecting data - const loopBW = 50; - const wn = 2 * Math.PI * loopBW / sampleRate; - const zeta = 0.707; // critically damped - this.loopAlpha = 2 * zeta * wn; - this.loopBeta = wn * wn; + // ── Pilot PLL: BPF at 19 kHz → quadrature mix → narrow LPF → I×Q phase error ── + this.pilotFreq = 2 * Math.PI * RDS_PILOT / sampleRate; + this.pilotBpf = makeBandpass(RDS_PILOT, 30, sampleRate); // Q=30, ~633 Hz BW + this.pilotAlpha = 2 * Math.PI * 100 / sampleRate; // High gain for fast pull-in + this.pilotMixAlpha = 1 - Math.exp(-2 * Math.PI * 30 / sampleRate); // 30 Hz LPF limits noise + + // 4th-order Butterworth LPF at 1.5 kHz for RDS baseband + this.bqI = makeButterworthLpf4(1500, sampleRate); + this.bqQ = makeButterworthLpf4(1500, sampleRate); } process(samples: Float32Array): void { for (let i = 0; i < samples.length; i++) { const sample = samples[i]; - // ── Mix with 57 kHz carrier (phase includes Costas correction) ── - const cosVal = Math.cos(this.carrierPhase); - const sinVal = Math.sin(this.carrierPhase); - this.carrierPhase += this.carrierPhaseInc; + // ── 19 kHz Pilot PLL ── + // 1. BPF extracts pilot tone from MPX (rejects audio, stereo, RDS) + const pilotBpf = this.pilotBpf.process(sample); + + // 2. Quadrature mix BPF output with PLL to get baseband I/Q + const pllCos = Math.cos(this.pilotPhase); + const pllSin = Math.sin(this.pilotPhase); + // 10 Hz LPF — rejects 38kHz mixing product AND audio beats near 19kHz + this.pilotMixI += this.pilotMixAlpha * (pilotBpf * pllCos - this.pilotMixI); + this.pilotMixQ += this.pilotMixAlpha * (pilotBpf * pllSin - this.pilotMixQ); + + // 3. Phase error: normalized I×Q (stable at φ=0 and φ=π) + const pp = this.pilotMixI * this.pilotMixI + this.pilotMixQ * this.pilotMixQ; + if (pp > 1e-12) { + this.pilotPhase -= (this.pilotMixI * this.pilotMixQ) / pp * this.pilotAlpha; + } + + // Debug: track pilot lock quality + this.dbgPilotIsum += Math.abs(this.pilotMixI); + this.dbgPilotQsum += Math.abs(this.pilotMixQ); + this.dbgPilotIQcount++; + + this.pilotPhase += this.pilotFreq; + if (this.pilotPhase > 2 * Math.PI) this.pilotPhase -= 2 * Math.PI; + else if (this.pilotPhase < 0) this.pilotPhase += 2 * Math.PI; - const rawI = sample * cosVal; - const rawQ = sample * sinVal; + // ── Coherent 57 kHz from triple-angle formulas ── + const c = pllCos, s = pllSin; + const cos3 = 4 * c * c * c - 3 * c; + const sin3 = 3 * s - 4 * s * s * s; + + // Mix MPX with coherent 57 kHz reference + const rawI = sample * cos3; + const rawQ = sample * sin3; // ── 4th-order Butterworth LPF on I and Q ── let filtI = rawI; @@ -228,42 +278,27 @@ export class RDSDecoder { let filtQ = rawQ; for (let k = 0; k < this.bqQ.length; k++) filtQ = this.bqQ[k].process(filtQ); - // ── Costas loop: carrier phase recovery ── - // Error signal: I×Q / (I²+Q²) — normalized, stable at φ=0 and φ=π - // Both lock points are valid for BPSK (differential decode handles π ambiguity) - const power = filtI * filtI + filtQ * filtQ; - if (power > 1e-12) { - const loopError = (filtI * filtQ) / power; - this.loopIntegrator += loopError * this.loopBeta; - // Clamp integrator to prevent wind-up (no freq offset in digital system) - if (this.loopIntegrator > 0.01) this.loopIntegrator = 0.01; - else if (this.loopIntegrator < -0.01) this.loopIntegrator = -0.01; - this.carrierPhase -= loopError * this.loopAlpha + this.loopIntegrator; - } - - // Wrap carrier phase - if (this.carrierPhase > 2 * Math.PI) this.carrierPhase -= 2 * Math.PI; - else if (this.carrierPhase < 0) this.carrierPhase += 2 * Math.PI; - // ── Clock recovery ── - // Zero-crossing PLL on I component (now aligned by Costas loop) this.clockPhase += 1.0; if ((filtI > 0) !== (this.prevBpskI > 0)) { const error = this.clockPhase - this.samplesPerBit / 2; this.clockPhase -= error * 0.1; + this.dbgClockCorr++; } this.prevBpskI = filtI; - // ── Sample at bit boundary ── + // ── Sample at bit boundary (mid-bit, maximum signal) ── if (this.clockPhase >= this.samplesPerBit) { this.clockPhase -= this.samplesPerBit; - // Differential decode using complex conjugate product: - // diffProd = Re{z[n] × conj(z[n-1])} = I·Iprev + Q·Qprev - // Positive → same phase (bit 0), Negative → π phase change (bit 1) - // This works regardless of carrier phase offset! + // Differential decode: Re{z[n] × conj(z[n-1])} const diffProd = filtI * this.prevSymI + filtQ * this.prevSymQ; const decodedBit = diffProd < 0 ? 1 : 0; + + this.dbgBitMagSum += Math.abs(filtI); + this.dbgBitDiffSum += Math.abs(diffProd); + this.dbgBitSamples++; + this.prevSymI = filtI; this.prevSymQ = filtQ; @@ -273,6 +308,33 @@ export class RDSDecoder { } private processBit(bit: number): void { + this.dbgBitsTotal++; + + // Periodic debug log every 5 seconds + const now = performance.now(); + if (now - this.dbgLastLog > 5000) { + const blockRate = this.dbgBlocksTotal > 0 ? (this.dbgBlocksValid / this.dbgBlocksTotal * 100).toFixed(1) : '0'; + const avgBitMag = this.dbgBitSamples > 0 ? (this.dbgBitMagSum / this.dbgBitSamples) : 0; + const avgDiffProd = this.dbgBitSamples > 0 ? (this.dbgBitDiffSum / this.dbgBitSamples) : 0; + const avgPI = this.dbgPilotIQcount > 0 ? (this.dbgPilotIsum / this.dbgPilotIQcount) : 0; + const avgPQ = this.dbgPilotIQcount > 0 ? (this.dbgPilotQsum / this.dbgPilotIQcount) : 0; + const qiRatio = avgPI > 1e-12 ? (avgPQ / avgPI * 100).toFixed(1) : '?'; + console.log(`[RDS] blk=${this.dbgBlocksValid}/${this.dbgBlocksTotal}(${blockRate}%) pllI=${avgPI.toExponential(2)} Q/I=${qiRatio}% |I|=${avgBitMag.toExponential(2)} |diff|=${avgDiffProd.toExponential(2)} clk=${this.dbgClockCorr}`); + this.dbgBitsTotal = 0; + this.dbgBlocksTotal = 0; + this.dbgBlocksValid = 0; + this.dbgGroupsDecoded = 0; + this.dbgSyncLost = 0; + this.dbgBitMagSum = 0; + this.dbgBitDiffSum = 0; + this.dbgBitSamples = 0; + this.dbgPilotIsum = 0; + this.dbgPilotQsum = 0; + this.dbgPilotIQcount = 0; + this.dbgClockCorr = 0; + this.dbgLastLog = now; + } + // Shift bit into 26-bit register this.shiftReg = ((this.shiftReg << 1) | bit) & 0x3FFFFFF; this.bitCount++; @@ -321,17 +383,20 @@ export class RDSDecoder { const isValid = (syn === expectedSyn) || (this.blockIndex === 2 && syn === SYNDROME_CP); + this.dbgBlocksTotal++; if (isValid) { this.blocks[this.blockIndex] = (this.shiftReg >> 10) & 0xFFFF; this.blockValid[this.blockIndex] = true; this.goodBlocks++; this.blockErrors = 0; + this.dbgBlocksValid++; } else { this.blockValid[this.blockIndex] = false; this.blockErrors++; - if (this.blockErrors > 10) { + if (this.blockErrors > 30) { // Lost sync this.synced = false; + this.dbgSyncLost++; this.goodBlocks = 0; this.blockErrors = 0; return; @@ -351,17 +416,14 @@ export class RDSDecoder { } } - /** Accept a character only after it has been received identically twice - * at the same position (standard RDS error-rejection technique). */ - private confirmChar(pos: number, char: number, chars: (number | null)[], confirm: (number | null)[]): void { - if (char < 0x20 || char >= 0x7F) return; - if (confirm[pos] === char) { - chars[pos] = char; // confirmed — accept - } - confirm[pos] = char; + /** Accept a printable character at a given position. + * CRC + block-validity checks upstream ensure data integrity. */ + private acceptChar(pos: number, char: number, chars: (number | null)[]): void { + if (char >= 0x20 && char < 0x7F) chars[pos] = char; } private decodeGroup(): void { + this.dbgGroupsDecoded++; const bv = this.blockValid; // Block A: PI code — only if block A passed CRC @@ -410,8 +472,8 @@ export class RDSDecoder { const c1 = (blockD >> 8) & 0xFF; const c2 = blockD & 0xFF; - this.confirmChar(segment * 2, c1, this.psChars, this.psConfirm); - this.confirmChar(segment * 2 + 1, c2, this.psChars, this.psConfirm); + this.acceptChar(segment * 2, c1, this.psChars); + this.acceptChar(segment * 2 + 1, c2, this.psChars); // Check if PS is complete (all 8 chars received) const ps = this.buildPS(); @@ -428,7 +490,6 @@ export class RDSDecoder { // A/B flag change means new RT message — clear buffer if (this.rtAbFlag !== -1 && abFlag !== this.rtAbFlag) { this.rtChars.fill(null); - this.rtConfirm.fill(null); } this.rtAbFlag = abFlag; @@ -443,10 +504,10 @@ export class RDSDecoder { const c3 = (blockD >> 8) & 0xFF; const c4 = blockD & 0xFF; const base = segment * 4; - this.confirmChar(base, c1, this.rtChars, this.rtConfirm); - this.confirmChar(base + 1, c2, this.rtChars, this.rtConfirm); - this.confirmChar(base + 2, c3, this.rtChars, this.rtConfirm); - this.confirmChar(base + 3, c4, this.rtChars, this.rtConfirm); + this.acceptChar(base, c1, this.rtChars); + this.acceptChar(base + 1, c2, this.rtChars); + this.acceptChar(base + 2, c3, this.rtChars); + this.acceptChar(base + 3, c4, this.rtChars); // Check for end-of-message marker (0x0D) if (c1 === 0x0D || c2 === 0x0D || c3 === 0x0D || c4 === 0x0D) { @@ -462,8 +523,8 @@ export class RDSDecoder { const c1 = (blockD >> 8) & 0xFF; const c2 = blockD & 0xFF; const base = segment * 2; - this.confirmChar(base, c1, this.rtChars, this.rtConfirm); - this.confirmChar(base + 1, c2, this.rtChars, this.rtConfirm); + this.acceptChar(base, c1, this.rtChars); + this.acceptChar(base + 1, c2, this.rtChars); } // Periodically emit partial RT @@ -507,18 +568,18 @@ export class RDSDecoder { this.tp = false; this.ta = false; this.psChars.fill(null); - this.psConfirm.fill(null); this.rtChars.fill(null); - this.rtConfirm.fill(null); this.rtAbFlag = -1; this.lastPs = ''; this.lastRt = ''; - this.carrierPhase = 0; + this.pilotPhase = 0; + this.pilotMixI = 0; + this.pilotMixQ = 0; + this.pilotBpf.reset(); this.clockPhase = 0; this.prevBpskI = 0; this.prevSymI = 0; this.prevSymQ = 0; - this.loopIntegrator = 0; for (const bq of this.bqI) bq.reset(); for (const bq of this.bqQ) bq.reset(); } From f8889cbaeaed8ae3523b4c6654e828d3890bebcf Mon Sep 17 00:00:00 2001 From: jLynx Date: Mon, 16 Mar 2026 21:00:04 +1300 Subject: [PATCH 3/5] feat(rds): Remove debug statistics from RDSDecoder for cleaner processing --- src/client/worker/rds.ts | 56 ---------------------------------------- 1 file changed, 56 deletions(-) diff --git a/src/client/worker/rds.ts b/src/client/worker/rds.ts index 2021634..a519714 100644 --- a/src/client/worker/rds.ts +++ b/src/client/worker/rds.ts @@ -179,21 +179,6 @@ export class RDSDecoder { private prevSymI: number = 0; private prevSymQ: number = 0; - // Debug stats (logged every 5 seconds) - private dbgLastLog: number = 0; - private dbgBitsTotal: number = 0; - private dbgBlocksTotal: number = 0; - private dbgBlocksValid: number = 0; - private dbgGroupsDecoded: number = 0; - private dbgSyncLost: number = 0; - private dbgBitMagSum: number = 0; // sum of |filtI| at bit boundaries - private dbgBitDiffSum: number = 0; // sum of |diffProd| at bit boundaries - private dbgBitSamples: number = 0; - private dbgPilotIsum: number = 0; // sum of |pilotLpfI| - private dbgPilotQsum: number = 0; // sum of |pilotLpfQ| - private dbgPilotIQcount: number = 0; - private dbgClockCorr: number = 0; // clock correction events - // Block assembly private shiftReg: number = 0; private bitCount: number = 0; @@ -254,11 +239,6 @@ export class RDSDecoder { this.pilotPhase -= (this.pilotMixI * this.pilotMixQ) / pp * this.pilotAlpha; } - // Debug: track pilot lock quality - this.dbgPilotIsum += Math.abs(this.pilotMixI); - this.dbgPilotQsum += Math.abs(this.pilotMixQ); - this.dbgPilotIQcount++; - this.pilotPhase += this.pilotFreq; if (this.pilotPhase > 2 * Math.PI) this.pilotPhase -= 2 * Math.PI; else if (this.pilotPhase < 0) this.pilotPhase += 2 * Math.PI; @@ -283,7 +263,6 @@ export class RDSDecoder { if ((filtI > 0) !== (this.prevBpskI > 0)) { const error = this.clockPhase - this.samplesPerBit / 2; this.clockPhase -= error * 0.1; - this.dbgClockCorr++; } this.prevBpskI = filtI; @@ -295,10 +274,6 @@ export class RDSDecoder { const diffProd = filtI * this.prevSymI + filtQ * this.prevSymQ; const decodedBit = diffProd < 0 ? 1 : 0; - this.dbgBitMagSum += Math.abs(filtI); - this.dbgBitDiffSum += Math.abs(diffProd); - this.dbgBitSamples++; - this.prevSymI = filtI; this.prevSymQ = filtQ; @@ -308,33 +283,6 @@ export class RDSDecoder { } private processBit(bit: number): void { - this.dbgBitsTotal++; - - // Periodic debug log every 5 seconds - const now = performance.now(); - if (now - this.dbgLastLog > 5000) { - const blockRate = this.dbgBlocksTotal > 0 ? (this.dbgBlocksValid / this.dbgBlocksTotal * 100).toFixed(1) : '0'; - const avgBitMag = this.dbgBitSamples > 0 ? (this.dbgBitMagSum / this.dbgBitSamples) : 0; - const avgDiffProd = this.dbgBitSamples > 0 ? (this.dbgBitDiffSum / this.dbgBitSamples) : 0; - const avgPI = this.dbgPilotIQcount > 0 ? (this.dbgPilotIsum / this.dbgPilotIQcount) : 0; - const avgPQ = this.dbgPilotIQcount > 0 ? (this.dbgPilotQsum / this.dbgPilotIQcount) : 0; - const qiRatio = avgPI > 1e-12 ? (avgPQ / avgPI * 100).toFixed(1) : '?'; - console.log(`[RDS] blk=${this.dbgBlocksValid}/${this.dbgBlocksTotal}(${blockRate}%) pllI=${avgPI.toExponential(2)} Q/I=${qiRatio}% |I|=${avgBitMag.toExponential(2)} |diff|=${avgDiffProd.toExponential(2)} clk=${this.dbgClockCorr}`); - this.dbgBitsTotal = 0; - this.dbgBlocksTotal = 0; - this.dbgBlocksValid = 0; - this.dbgGroupsDecoded = 0; - this.dbgSyncLost = 0; - this.dbgBitMagSum = 0; - this.dbgBitDiffSum = 0; - this.dbgBitSamples = 0; - this.dbgPilotIsum = 0; - this.dbgPilotQsum = 0; - this.dbgPilotIQcount = 0; - this.dbgClockCorr = 0; - this.dbgLastLog = now; - } - // Shift bit into 26-bit register this.shiftReg = ((this.shiftReg << 1) | bit) & 0x3FFFFFF; this.bitCount++; @@ -383,20 +331,17 @@ export class RDSDecoder { const isValid = (syn === expectedSyn) || (this.blockIndex === 2 && syn === SYNDROME_CP); - this.dbgBlocksTotal++; if (isValid) { this.blocks[this.blockIndex] = (this.shiftReg >> 10) & 0xFFFF; this.blockValid[this.blockIndex] = true; this.goodBlocks++; this.blockErrors = 0; - this.dbgBlocksValid++; } else { this.blockValid[this.blockIndex] = false; this.blockErrors++; if (this.blockErrors > 30) { // Lost sync this.synced = false; - this.dbgSyncLost++; this.goodBlocks = 0; this.blockErrors = 0; return; @@ -423,7 +368,6 @@ export class RDSDecoder { } private decodeGroup(): void { - this.dbgGroupsDecoded++; const bv = this.blockValid; // Block A: PI code — only if block A passed CRC From a69a1dba2a7b8c9412532218a4617000db4e7763 Mon Sep 17 00:00:00 2001 From: jLynx Date: Mon, 16 Mar 2026 21:20:59 +1300 Subject: [PATCH 4/5] feat(rds): Integrate RDSDecoder into dsp-worker for in-worker RDS decoding --- src/client/dsp-worker.ts | 24 ++++++++----- src/client/worker/rds.ts | 65 +++++++++++++++++++++------------- src/client/worker/rx-stream.ts | 19 +++------- 3 files changed, 61 insertions(+), 47 deletions(-) diff --git a/src/client/dsp-worker.ts b/src/client/dsp-worker.ts index 286bc7e..6beb58c 100644 --- a/src/client/dsp-worker.ts +++ b/src/client/dsp-worker.ts @@ -1,5 +1,6 @@ import init, { DspProcessor, set_panic_hook, alloc_iq_buffer, free_iq_buffer } from "/hackrf-web/pkg/hackrf_web.js"; import { RationalResampler } from './worker/dsp-pipeline'; +import { RDSDecoder } from './worker/rds'; // --- Worker State --- let wasmInitPromise: Promise | null = null; @@ -10,6 +11,7 @@ let sharedIqPtr = 0; let sharedSabViews: Int8Array[] | null = null; let rdsDdc: any = null; let rdsPrevPhase = 0; +let rdsDecoder: InstanceType | null = null; const IF_RATES: Record = { nfm: 50000, @@ -115,8 +117,8 @@ self.onmessage = async (e: MessageEvent) => { self.postMessage({ type: "audio", samples: null, chunkId: msg.chunkId, squelchOpen: vfoState.squelchOpen, dspTime: dspTime }); } - // RDS MPX extraction via second DspProcessor - if (rdsDdc && msg.params.rds && msg.params.mode === 'wfm') { + // RDS: extract MPX and decode in-worker (avoids blocking the audio mixer thread) + if (rdsDdc && rdsDecoder && msg.params.rds && msg.params.mode === 'wfm') { const chunkLen = msg.useSab ? msg.chunkLen : (msg.chunk ? msg.chunk.byteLength : 0); if (chunkLen > 0) { const iqPtr = rdsDdc.process_iq_only_ptr(sharedIqPtr, chunkLen); @@ -133,7 +135,8 @@ self.onmessage = async (e: MessageEvent) => { mpxOut[i] = diff; rdsPrevPhase = ph; } - (self as any).postMessage({ type: "rds_mpx", mpx: mpxOut.buffer }, [mpxOut.buffer]); + // Decode RDS in this worker thread — decoded messages sent via callback + rdsDecoder.process(mpxOut); } } } @@ -163,17 +166,22 @@ function configureDDC(params: any, systemCenterFreq: number): void { // Apply UI audio filters (High Pass 300Hz, Low Pass BW/2) ddc.set_audio_filters(params.lowPass || false, params.highPass || false); - // RDS: second DspProcessor for MPX extraction (250kHz I/Q) + // RDS: second DspProcessor for MPX extraction + in-worker RDS decoder if (params.rds && params.mode === 'wfm') { if (!rdsDdc) { rdsDdc = new DspProcessor(systemSampleRate, 0.0, 250000); rdsDdc.set_if_sample_rate(250000); - rdsPrevPhase = 0; // Only reset on initial creation, not every configure + rdsPrevPhase = 0; + } + if (!rdsDecoder) { + rdsDecoder = new RDSDecoder(250000, (rmsg: any) => { + (self as any).postMessage({ type: "rds", msg: rmsg }); + }, params.rdsRegion || 'eu'); } rdsDdc.set_shift(systemSampleRate, offsetFreq); - } else if (rdsDdc) { - rdsDdc.free(); - rdsDdc = null; + } else { + if (rdsDdc) { rdsDdc.free(); rdsDdc = null; } + rdsDecoder = null; } } diff --git a/src/client/worker/rds.ts b/src/client/worker/rds.ts index a519714..d97850b 100644 --- a/src/client/worker/rds.ts +++ b/src/client/worker/rds.ts @@ -158,13 +158,17 @@ export class RDSDecoder { private region: string; // 19 kHz pilot PLL — BPF + quadrature mixing + ultra-narrow LPF - private pilotPhase: number = 0; - private pilotFreq: number; // nominal 2π×19000/fs + // Uses phasor rotation (complex multiply) instead of Math.cos/sin per sample + private phasorRe: number = 1; // cos(pilotPhase) + private phasorIm: number = 0; // sin(pilotPhase) + private incRe: number; // cos(pilotFreq) — precomputed rotation + private incIm: number; // sin(pilotFreq) private pilotAlpha: number; // PLL proportional gain private pilotBpf: BiquadSection; // 19 kHz bandpass filter private pilotMixI: number = 0; // mixed pilot baseband I (after narrow LPF) private pilotMixQ: number = 0; // mixed pilot baseband Q (after narrow LPF) - private pilotMixAlpha: number; // ultra-narrow LPF coefficient + private pilotMixAlpha: number; // narrow LPF coefficient + private phasorCount: number = 0; // renormalize counter // 4th-order Butterworth LPF for RDS I and Q channels private bqI: BiquadSection[]; @@ -205,13 +209,16 @@ export class RDSDecoder { constructor(sampleRate: number, callback: (msg: RDSMessage) => void, region: string = 'eu') { this.callback = callback; this.region = region; - this.samplesPerBit = sampleRate / RDS_BITRATE; // ── Pilot PLL: BPF at 19 kHz → quadrature mix → narrow LPF → I×Q phase error ── - this.pilotFreq = 2 * Math.PI * RDS_PILOT / sampleRate; + // Phasor rotation: precompute cos/sin of frequency increment (done once) + const pilotFreq = 2 * Math.PI * RDS_PILOT / sampleRate; + this.incRe = Math.cos(pilotFreq); + this.incIm = Math.sin(pilotFreq); this.pilotBpf = makeBandpass(RDS_PILOT, 30, sampleRate); // Q=30, ~633 Hz BW this.pilotAlpha = 2 * Math.PI * 100 / sampleRate; // High gain for fast pull-in this.pilotMixAlpha = 1 - Math.exp(-2 * Math.PI * 30 / sampleRate); // 30 Hz LPF limits noise + this.samplesPerBit = sampleRate / RDS_BITRATE; // 4th-order Butterworth LPF at 1.5 kHz for RDS baseband this.bqI = makeButterworthLpf4(1500, sampleRate); @@ -222,26 +229,38 @@ export class RDSDecoder { for (let i = 0; i < samples.length; i++) { const sample = samples[i]; - // ── 19 kHz Pilot PLL ── - // 1. BPF extracts pilot tone from MPX (rejects audio, stereo, RDS) + // ── 19 kHz Pilot PLL (zero trig calls — phasor rotation only) ── const pilotBpf = this.pilotBpf.process(sample); - // 2. Quadrature mix BPF output with PLL to get baseband I/Q - const pllCos = Math.cos(this.pilotPhase); - const pllSin = Math.sin(this.pilotPhase); - // 10 Hz LPF — rejects 38kHz mixing product AND audio beats near 19kHz + // Current phasor = (cos(θ), sin(θ)) + const pllCos = this.phasorRe; + const pllSin = this.phasorIm; + + // Quadrature mix + narrow LPF this.pilotMixI += this.pilotMixAlpha * (pilotBpf * pllCos - this.pilotMixI); this.pilotMixQ += this.pilotMixAlpha * (pilotBpf * pllSin - this.pilotMixQ); - // 3. Phase error: normalized I×Q (stable at φ=0 and φ=π) + // Phase error → small-angle phasor correction const pp = this.pilotMixI * this.pilotMixI + this.pilotMixQ * this.pilotMixQ; if (pp > 1e-12) { - this.pilotPhase -= (this.pilotMixI * this.pilotMixQ) / pp * this.pilotAlpha; + const corr = (this.pilotMixI * this.pilotMixQ) / pp * this.pilotAlpha; + // Rotate phasor by -corr (small angle: cos≈1, sin≈-corr) + this.phasorRe += this.phasorIm * corr; + this.phasorIm -= this.phasorRe * corr; } - this.pilotPhase += this.pilotFreq; - if (this.pilotPhase > 2 * Math.PI) this.pilotPhase -= 2 * Math.PI; - else if (this.pilotPhase < 0) this.pilotPhase += 2 * Math.PI; + // Advance phasor by one sample (complex multiply with increment) + const newRe = this.phasorRe * this.incRe - this.phasorIm * this.incIm; + const newIm = this.phasorRe * this.incIm + this.phasorIm * this.incRe; + this.phasorRe = newRe; + this.phasorIm = newIm; + + // Renormalize every 1024 samples to prevent amplitude drift + if ((++this.phasorCount & 1023) === 0) { + const mag = 1 / Math.sqrt(this.phasorRe * this.phasorRe + this.phasorIm * this.phasorIm); + this.phasorRe *= mag; + this.phasorIm *= mag; + } // ── Coherent 57 kHz from triple-angle formulas ── const c = pllCos, s = pllSin; @@ -252,13 +271,13 @@ export class RDSDecoder { const rawI = sample * cos3; const rawQ = sample * sin3; - // ── 4th-order Butterworth LPF on I and Q ── + // 4th-order Butterworth LPF on I and Q let filtI = rawI; for (let k = 0; k < this.bqI.length; k++) filtI = this.bqI[k].process(filtI); let filtQ = rawQ; for (let k = 0; k < this.bqQ.length; k++) filtQ = this.bqQ[k].process(filtQ); - // ── Clock recovery ── + // Clock recovery this.clockPhase += 1.0; if ((filtI > 0) !== (this.prevBpskI > 0)) { const error = this.clockPhase - this.samplesPerBit / 2; @@ -266,17 +285,13 @@ export class RDSDecoder { } this.prevBpskI = filtI; - // ── Sample at bit boundary (mid-bit, maximum signal) ── + // Sample at bit boundary if (this.clockPhase >= this.samplesPerBit) { this.clockPhase -= this.samplesPerBit; - - // Differential decode: Re{z[n] × conj(z[n-1])} const diffProd = filtI * this.prevSymI + filtQ * this.prevSymQ; const decodedBit = diffProd < 0 ? 1 : 0; - this.prevSymI = filtI; this.prevSymQ = filtQ; - this.processBit(decodedBit); } } @@ -516,7 +531,9 @@ export class RDSDecoder { this.rtAbFlag = -1; this.lastPs = ''; this.lastRt = ''; - this.pilotPhase = 0; + this.phasorRe = 1; + this.phasorIm = 0; + this.phasorCount = 0; this.pilotMixI = 0; this.pilotMixQ = 0; this.pilotBpf.reset(); diff --git a/src/client/worker/rx-stream.ts b/src/client/worker/rx-stream.ts index 6a2bdc6..0f850a5 100644 --- a/src/client/worker/rx-stream.ts +++ b/src/client/worker/rx-stream.ts @@ -22,7 +22,6 @@ import * as Comlink from 'comlink'; import { FFT } from './wasm-init'; import { RationalResampler } from './dsp-pipeline'; import { POCSAGDecoder } from './pocsag'; -import { RDSDecoder } from './rds'; import type { RxStreamOpts, VfoParams, VfoState, PerfCounters } from './types'; import { IF_RATES, AUDIO_RATE } from './types'; import type { Backend } from './backend'; @@ -119,8 +118,10 @@ export async function startRxStream( const msg = e.data; if (msg.type === "audio") { backend._handleWorkerAudio!(index, msg); - } else if (msg.type === "rds_mpx") { - handleRdsMpx(index, msg); + } else if (msg.type === "rds") { + // Decoded RDS message from dsp-worker — just forward to main thread + const params = backend.vfoParams![index]; + if (rdsCallback && params) rdsCallback(index, params.freq, msg.msg); } else if (msg.type === "error") { console.error(`[DSP Worker ${index}] Error:`, msg.error); } @@ -492,18 +493,6 @@ export async function startRxStream( } }; - const handleRdsMpx = (v: number, msg: any): void => { - const state = backend.vfoStates![v]; - const params = backend.vfoParams![v]; - if (!state || !params || !params.rds || params.mode !== 'wfm') return; - if (!state.rdsDecoder) { - state.rdsDecoder = new RDSDecoder(250000, (rmsg: any) => { - if (rdsCallback) rdsCallback(v, params.freq, rmsg); - }, params.rdsRegion || 'eu'); - } - state.rdsDecoder.process(new Float32Array(msg.mpx)); - }; - let chunkCounter = 0; const handleWorkerAudio = (v: number, msg: any): void => { From 802c3c3f9f8434bc2f53e5af269892118754b7ac Mon Sep 17 00:00:00 2001 From: jLynx Date: Mon, 16 Mar 2026 21:36:11 +1300 Subject: [PATCH 5/5] feat(rds): Refactor RDS state management and enhance UI for station display --- src/client/app/rds.ts | 53 +++++++++++++++++++++----------- src/client/app/state.ts | 10 +----- src/client/index.html | 56 +++++++++++++++++++++------------- src/client/style.css | 67 ++++++++++++++++++++++++++++++++--------- 4 files changed, 123 insertions(+), 63 deletions(-) diff --git a/src/client/app/rds.ts b/src/client/app/rds.ts index 4c5375b..85b309f 100644 --- a/src/client/app/rds.ts +++ b/src/client/app/rds.ts @@ -1,34 +1,57 @@ import type { AppInstance } from './types'; +const EMPTY_STATION = { ps: '', rt: '', pi: '', pty: 0, ptyLabel: '', tp: false, ta: false, freq: '' }; + export const rdsMethods = { toggleRdsPanel(this: AppInstance) { this.rds.panelOpen = !this.rds.panelOpen; }, + /** Get (or create) the per-VFO RDS station state */ + _rdsStation(this: AppInstance, vfoIndex: number) { + if (!this.rds.stations[vfoIndex]) { + this.rds.stations[vfoIndex] = { ...EMPTY_STATION }; + } + return this.rds.stations[vfoIndex]; + }, + /** Get the active VFO's RDS station (for display in header) */ + rdsActive(this: AppInstance) { + return this.rds.stations[this.activeVfoIndex] || EMPTY_STATION; + }, + /** Get all VFOs that have RDS data, as an array with vfoIndex attached */ + rdsStationList(this: AppInstance): Array<{ vfoIndex: number; ps: string; rt: string; pi: string; pty: number; ptyLabel: string; tp: boolean; ta: boolean; freq: string }> { + const result: Array = []; + for (const key of Object.keys(this.rds.stations)) { + const idx = Number(key); + const stn = this.rds.stations[idx]; + if (stn && (stn.ps || stn.pi || stn.rt)) + result.push({ ...stn, vfoIndex: idx }); + } + return result; + }, _onRdsMessage(this: AppInstance, vfoIndex: number, freqMhz: number, msg: any) { const time = new Date().toLocaleTimeString(); const freq = freqMhz ? this.formatFreq(freqMhz) + ' MHz' : ''; + const stn = this._rdsStation(vfoIndex); if (msg.pi !== undefined) { - this.rds.pi = msg.pi; - this.rds.freq = freq; - this.rds.vfoIndex = vfoIndex; + stn.pi = msg.pi; + stn.freq = freq; } if (msg.ps !== undefined) { - this.rds.ps = msg.ps; - this.rds.freq = freq; - this.rds.vfoIndex = vfoIndex; + stn.ps = msg.ps; + stn.freq = freq; this.rds.log.push({ time, field: 'PS', value: msg.ps, freq, vfoIndex }); } if (msg.rt !== undefined) { - this.rds.rt = msg.rt; + stn.rt = msg.rt; this.rds.log.push({ time, field: 'RT', value: msg.rt, freq, vfoIndex }); } if (msg.pty !== undefined) { - this.rds.pty = msg.pty; - this.rds.ptyLabel = msg.ptyLabel || ''; + stn.pty = msg.pty; + stn.ptyLabel = msg.ptyLabel || ''; } - if (msg.tp !== undefined) this.rds.tp = msg.tp; - if (msg.ta !== undefined) this.rds.ta = msg.ta; + if (msg.tp !== undefined) stn.tp = msg.tp; + if (msg.ta !== undefined) stn.ta = msg.ta; // Auto-scroll log this.$nextTick(() => { @@ -38,13 +61,7 @@ export const rdsMethods = { }, clearRds(this: AppInstance) { this.rds.log = []; - this.rds.ps = ''; - this.rds.rt = ''; - this.rds.pi = ''; - this.rds.pty = 0; - this.rds.ptyLabel = ''; - this.rds.tp = false; - this.rds.ta = false; + this.rds.stations = {}; }, exportRds(this: AppInstance) { const lines = this.rds.log.map((e: any) => diff --git a/src/client/app/state.ts b/src/client/app/state.ts index 9f98e9b..1d41bc7 100644 --- a/src/client/app/state.ts +++ b/src/client/app/state.ts @@ -76,15 +76,7 @@ export function createAppData() { }, rds: { panelOpen: false, - ps: '', - rt: '', - pi: '', - pty: 0, - ptyLabel: '', - tp: false, - ta: false, - freq: '', - vfoIndex: null as number | null, + stations: {} as Record, log: [] as Array<{ time: string; field: string; value: string; freq: string; vfoIndex: number }>, }, bookmarkCategories: BOOKMARK_CATEGORIES, diff --git a/src/client/index.html b/src/client/index.html index 89e7fd0..a0e187b 100644 --- a/src/client/index.html +++ b/src/client/index.html @@ -596,34 +596,48 @@
FM RDS Decoder - {{ rds.ps || rds.pi }} + {{ rdsStationList().length }} station{{ rdsStationList().length > 1 ? 's' : '' }}
-
- {{ rds.ps }} - PI:{{ rds.pi }} - {{ rds.ptyLabel }} - - TP - TA - -
-
{{ rds.rt }}
-
-
- Enable "Decode RDS" on a WFM VFO and tune to an FM station to see station data. +
+ +
+
+ Enable "Decode RDS" on a WFM VFO and tune to an FM station. +
+
+
+ {{ stn.ps }} + {{ stn.freq }} +
+
+ PI:{{ stn.pi }} + {{ stn.ptyLabel }} + + TP + TA + +
+
{{ stn.rt }}
+
-
- - {{ entry.time }} - {{ entry.freq }} - {{ entry.field }} - {{ entry.value }} + +
+
+ Waiting for RDS data... +
+
+ + {{ entry.time }} + {{ entry.freq }} + {{ entry.field }} + {{ entry.value }} +
diff --git a/src/client/style.css b/src/client/style.css index 2b7142c..7a82e47 100644 --- a/src/client/style.css +++ b/src/client/style.css @@ -1144,33 +1144,72 @@ canvas { } /* ────── RDS ────── */ -.rds-station { +.rds-split { display: flex; - align-items: center; - gap: 12px; - padding: 8px 12px; - background: #1a1a2e; + flex: 1; + min-height: 0; + border-top: 1px solid var(--border); +} + +.rds-stations-col { + width: 40%; + min-width: 200px; + overflow-y: auto; + border-right: 1px solid var(--border); +} + +.rds-log-col { + flex: 1; + overflow-y: auto; + padding: 6px 12px; + font-family: 'Roboto Mono', monospace; + font-size: 11px; +} + +.rds-station-card { + border-left: 3px solid #666; border-bottom: 1px solid var(--border); + padding: 6px 10px; + background: #1a1a2e; font-family: 'Roboto Mono', monospace; } +.rds-station { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.rds-station-meta { + display: flex; + align-items: center; + gap: 8px; + margin-top: 2px; +} + .rds-ps { - font-size: 18px; + font-size: 15px; font-weight: 700; color: #4fc3f7; - letter-spacing: 2px; + letter-spacing: 1px; } -.rds-pi { +.rds-freq { font-size: 11px; + color: #aaa; +} + +.rds-pi { + font-size: 10px; color: #80cbc4; } .rds-pty { - font-size: 11px; + font-size: 10px; color: #ce93d8; background: #4a148c; - padding: 1px 6px; + padding: 1px 5px; border-radius: 3px; } @@ -1195,15 +1234,13 @@ canvas { } .rds-rt { - padding: 6px 12px; - background: #111; - border-bottom: 1px solid var(--border); - font-family: 'Roboto Mono', monospace; - font-size: 12px; + margin-top: 3px; + font-size: 11px; color: #e0e0e0; letter-spacing: 0.5px; white-space: pre-wrap; word-break: break-word; + opacity: 0.85; } /* ────── Bookmarks ────── */