Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/client/app/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions src/client/app/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -36,6 +37,7 @@ createApp({
...bookmarkMethods,
...whisperMethods,
...pocsagMethods,
...rdsMethods,
...zoomMethods,
...remoteMethods,
},
Expand Down
78 changes: 78 additions & 0 deletions src/client/app/rds.ts
Original file line number Diff line number Diff line change
@@ -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<any> = [];
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);
},
};
5 changes: 5 additions & 0 deletions src/client/app/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number, { ps: string; rt: string; pi: string; pty: number; ptyLabel: string; tp: boolean; ta: boolean; freq: string }>,
log: [] as Array<{ time: string; field: string; value: string; freq: string; vfoIndex: number }>,
},
bookmarkCategories: BOOKMARK_CATEGORIES,
bookmarkCategoryFilter: '',
bookmarkSearch: '',
Expand Down
46 changes: 46 additions & 0 deletions src/client/dsp-worker.ts
Original file line number Diff line number Diff line change
@@ -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<void> | null = null;
Expand All @@ -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<typeof RDSDecoder> | null = null;

const IF_RATES: Record<string, number> = {
nfm: 50000,
Expand Down Expand Up @@ -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 });
}
Expand All @@ -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 {
Expand Down
57 changes: 56 additions & 1 deletion src/client/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@
<button class="icon-btn" @click="togglePocsagPanel" :class="{ active: pocsag.panelOpen }" title="POCSAG Pager Decoder">
<svg viewBox="0 0 24 24" width="24" height="24"><path fill="currentColor" d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-3 12H7v-2h10v2zm0-3H7V9h10v2zm0-3H7V6h10v2z"/></svg>
</button>
<button class="icon-btn" @click="toggleRdsPanel" :class="{ active: rds.panelOpen }" title="FM RDS Decoder">
<svg viewBox="0 0 24 24" width="24" height="24"><path fill="currentColor" d="M20 6H4c-1.1 0-2 .9-2 2v8c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm0 10H4V8h16v8zM6 10h2v4H6zm3 0h5v4H9z"/></svg>
</button>
<button class="icon-btn" @click="showActivity = !showActivity" :class="{ active: showActivity }" title="Frequency Activity Tracker">
<svg viewBox="0 0 24 24" width="24" height="24"><path fill="currentColor" d="M3.5 18.49l6-6.01 4 4L22 6.92l-1.41-1.41-7.09 7.97-4-4L2 16.99l1.5 1.5z"/></svg>
</button>
Expand Down Expand Up @@ -290,7 +293,7 @@

<div class="form-row full-checkbox-row" v-if="vfo.mode === 'wfm'">
<label class="custom-checkbox">
<input type="checkbox" v-model="vfo.rds">
<input type="checkbox" v-model="vfo.rds" @change="updateBackendVfoParams(i)">
<span class="checkmark"></span>
Decode RDS
</label>
Expand Down Expand Up @@ -587,6 +590,58 @@
</div>
</div>

<!-- RDS Panel -->
<div class="pocsag-panel" v-if="rds.panelOpen">
<div class="pocsag-header">
<div class="pocsag-title">
<svg viewBox="0 0 24 24" width="16" height="16" style="vertical-align:middle;margin-right:4px"><path fill="currentColor" d="M20 6H4c-1.1 0-2 .9-2 2v8c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm0 10H4V8h16v8zM6 10h2v4H6zm3 0h5v4H9z"/></svg>
FM RDS Decoder
<span class="pocsag-count" v-if="rdsStationList().length">{{ rdsStationList().length }} station{{ rdsStationList().length > 1 ? 's' : '' }}</span>
</div>
<div class="pocsag-controls">
<button class="btn transcript-btn" @click="clearRds" title="Clear RDS data">Clear</button>
<button class="btn transcript-btn" @click="exportRds" title="Export as text" :disabled="rds.log.length === 0">Export</button>
</div>
</div>
<div class="rds-split">
<!-- Left: Station cards -->
<div class="rds-stations-col">
<div v-if="rdsStationList().length === 0" class="pocsag-empty">
Enable "Decode RDS" on a WFM VFO and tune to an FM station.
</div>
<div v-for="stn in rdsStationList()" :key="stn.vfoIndex" class="rds-station-card" :style="{ borderLeftColor: vfoColor(stn.vfoIndex) }">
<div class="rds-station">
<span class="rds-ps" v-if="stn.ps">{{ stn.ps }}</span>
<span class="rds-freq">{{ stn.freq }}</span>
</div>
<div class="rds-station-meta">
<span class="rds-pi" v-if="stn.pi">PI:{{ stn.pi }}</span>
<span class="rds-pty" v-if="stn.ptyLabel">{{ stn.ptyLabel }}</span>
<span class="rds-flags" v-if="stn.tp || stn.ta">
<span v-if="stn.tp" class="rds-flag">TP</span>
<span v-if="stn.ta" class="rds-flag rds-ta">TA</span>
</span>
</div>
<div class="rds-rt" v-if="stn.rt">{{ stn.rt }}</div>
</div>
</div>
<!-- Right: Live log -->
<div class="rds-log-col" ref="rdsBody">
<div v-if="rds.log.length === 0" class="pocsag-empty">
Waiting for RDS data...
</div>
<div v-for="(entry, i) in rds.log" :key="i" class="pocsag-entry"
:style="entry.vfoIndex != null ? { borderLeftColor: vfoColor(entry.vfoIndex) } : {}">
<span class="pocsag-vfo-dot" v-if="entry.vfoIndex != null" :style="{ background: vfoColor(entry.vfoIndex) }" :title="'VFO ' + (entry.vfoIndex + 1)"></span>
<span class="pocsag-time">{{ entry.time }}</span>
<span class="pocsag-freq" :style="entry.vfoIndex != null ? { color: vfoColor(entry.vfoIndex) } : {}">{{ entry.freq }}</span>
<span class="pocsag-type">{{ entry.field }}</span>
<span class="pocsag-text">{{ entry.value }}</span>
</div>
</div>
</div>
</div>

<!-- Frequency Activity Panel -->
<div class="activity-panel" v-if="showActivity">
<div class="activity-header">
Expand Down
100 changes: 100 additions & 0 deletions src/client/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
Loading