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..85b309f --- /dev/null +++ b/src/client/app/rds.ts @@ -0,0 +1,78 @@ +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) { + stn.pi = msg.pi; + stn.freq = freq; + } + if (msg.ps !== undefined) { + stn.ps = msg.ps; + stn.freq = freq; + this.rds.log.push({ time, field: 'PS', value: msg.ps, freq, vfoIndex }); + } + if (msg.rt !== undefined) { + stn.rt = msg.rt; + this.rds.log.push({ time, field: 'RT', value: msg.rt, freq, vfoIndex }); + } + if (msg.pty !== undefined) { + stn.pty = msg.pty; + stn.ptyLabel = msg.ptyLabel || ''; + } + if (msg.tp !== undefined) stn.tp = msg.tp; + if (msg.ta !== undefined) stn.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.stations = {}; + }, + 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..1d41bc7 100644 --- a/src/client/app/state.ts +++ b/src/client/app/state.ts @@ -74,6 +74,11 @@ 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, + stations: {} as Record, + 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..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; @@ -8,6 +9,9 @@ let ddc: any; let vfoState: any; 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, @@ -112,6 +116,30 @@ self.onmessage = async (e: MessageEvent) => { } else { self.postMessage({ type: "audio", samples: null, chunkId: msg.chunkId, squelchOpen: vfoState.squelchOpen, dspTime: dspTime }); } + + // 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); + 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; + } + // Decode RDS in this worker thread — decoded messages sent via callback + rdsDecoder.process(mpxOut); + } + } + } } catch (err: any) { self.postMessage({ type: "error", error: err.message }); } @@ -137,6 +165,24 @@ 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 + 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; + } + 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; } + rdsDecoder = null; + } } function processVfoAudio(chunkLenBytes: number, params: any): Float32Array | null { diff --git a/src/client/index.html b/src/client/index.html index b430282..a0e187b 100644 --- a/src/client/index.html +++ b/src/client/index.html @@ -84,6 +84,9 @@ + @@ -290,7 +293,7 @@
@@ -587,6 +590,58 @@
+ +
+
+
+ + FM RDS Decoder + {{ rdsStationList().length }} station{{ rdsStationList().length > 1 ? 's' : '' }} +
+
+ + +
+
+
+ +
+
+ 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 }}
+
+
+ +
+
+ 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 db49086..7a82e47 100644 --- a/src/client/style.css +++ b/src/client/style.css @@ -1143,6 +1143,106 @@ canvas { font-style: italic; } +/* ────── RDS ────── */ +.rds-split { + display: flex; + 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: 15px; + font-weight: 700; + color: #4fc3f7; + letter-spacing: 1px; +} + +.rds-freq { + font-size: 11px; + color: #aaa; +} + +.rds-pi { + font-size: 10px; + color: #80cbc4; +} + +.rds-pty { + font-size: 10px; + color: #ce93d8; + background: #4a148c; + padding: 1px 5px; + 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 { + margin-top: 3px; + font-size: 11px; + color: #e0e0e0; + letter-spacing: 0.5px; + white-space: pre-wrap; + word-break: break-word; + opacity: 0.85; +} + /* ────── 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..d97850b --- /dev/null +++ b/src/client/worker/rds.ts @@ -0,0 +1,547 @@ +/* +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_PILOT = 19000; // stereo pilot — RDS subcarrier = 3 × pilot + +// 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 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[] { + // 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; + + // 19 kHz pilot PLL — BPF + quadrature mixing + ultra-narrow LPF + // 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; // narrow LPF coefficient + private phasorCount: number = 0; // renormalize counter + + // 4th-order Butterworth LPF for RDS I and Q channels + private bqI: BiquadSection[]; + private bqQ: BiquadSection[]; + + // Clock recovery (1187.5 bps) + private samplesPerBit: number; + private clockPhase: number = 0; + private prevBpskI: number = 0; + + // Differential decode using complex conjugate product + 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 rtChars: (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; + + // ── Pilot PLL: BPF at 19 kHz → quadrature mix → narrow LPF → I×Q phase error ── + // 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); + this.bqQ = makeButterworthLpf4(1500, sampleRate); + } + + process(samples: Float32Array): void { + for (let i = 0; i < samples.length; i++) { + const sample = samples[i]; + + // ── 19 kHz Pilot PLL (zero trig calls — phasor rotation only) ── + const pilotBpf = this.pilotBpf.process(sample); + + // 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); + + // Phase error → small-angle phasor correction + const pp = this.pilotMixI * this.pilotMixI + this.pilotMixQ * this.pilotMixQ; + if (pp > 1e-12) { + 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; + } + + // 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; + 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; + 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 + 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; + 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 > 30) { + // 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 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 { + 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.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(); + 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.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.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) { + 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.acceptChar(base, c1, this.rtChars); + this.acceptChar(base + 1, c2, this.rtChars); + } + + // 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.rtChars.fill(null); + this.rtAbFlag = -1; + this.lastPs = ''; + this.lastRt = ''; + this.phasorRe = 1; + this.phasorIm = 0; + this.phasorCount = 0; + this.pilotMixI = 0; + this.pilotMixQ = 0; + this.pilotBpf.reset(); + this.clockPhase = 0; + this.prevBpskI = 0; + this.prevSymI = 0; + this.prevSymQ = 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..0f850a5 100644 --- a/src/client/worker/rx-stream.ts +++ b/src/client/worker/rx-stream.ts @@ -32,7 +32,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 +84,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 +105,7 @@ export async function startRxStream( const makeVfoState = (): VfoState => ({ squelchOpen: false, pocsagDecoder: null, + rdsDecoder: null, audioQueue: new Float32Array(32768), audioQueueLen: 0, }); @@ -116,6 +118,10 @@ export async function startRxStream( const msg = e.data; if (msg.type === "audio") { backend._handleWorkerAudio!(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); } @@ -532,6 +538,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;