feat(sdr): local receivers — browser RTL-SDR, Local ADS-B layer and decoder feeds (#732)

Adds a Local ADS-B layer fed by your own receivers: a browser WebUSB
RTL-SDR (with FM listening) and local 1090/978 MHz decoder feeds
(LOCAL_RECEIVER_FEEDS, local and private addresses only). Local aircraft
draw in magenta with class, silhouettes, 3D models, trails and a position
sanity filter; the click card names the band and input that heard them.

Builds on Sameh Khamis's SDR prototype and the initial GEV port by
Mazeyar Moeini Feizabadi (#134); the decoder-feed design follows the
notes on #57. Closes #57.

Co-authored-by: Sameh Khamis <[email protected]>
Co-authored-by: Mazeyar Moeini Feizabadi <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-09-23 16:00:53 -05:00
1 parent 143cffd7ca
commit 1fc795577a
78 files changed
+12267 -108

No files matched your search

+28 -2
View File
@@ -16,6 +16,7 @@ import { CockpitCoordinator } from './cockpitCoordinator.js';
import { ContextControls } from './context.js';
import { CctvControls } from './cctv.js';
import { RadioControls } from './radio.js';
import { LocalSdrControls } from './localSdrControls.js';
import { LocationNavigation } from './locationNavigation.js';
import { bindClearLayersControl } from './layers.js';
import { bindCameraOrientationControls } from './cameraOrientationControls.js';
@@ -77,6 +78,7 @@ export class StyleManager extends ShellFacade {
transitLayer,
aisLiveVesselsLayer,
militaryAwarenessLayer,
localAdsbLayer,
} = services;
this.services = services;
this._lifetime = new UiLifetime();
@@ -449,12 +451,13 @@ export class StyleManager extends ShellFacade {
trafficLayer,
flightsLayer,
militaryFlightsLayer,
localAdsbLayer,
satellitesLayer,
cctvLayer,
bikeshareLayer,
transitLayer,
aisLiveVesselsLayer,
],
].filter(Boolean),
(modeLabel) => {
this._updateDetectionButton(modeLabel);
},
@@ -860,11 +863,33 @@ export class StyleManager extends ShellFacade {
isCockpitActive: () => this.cockpitView?.active,
signalUserCollapsed: () => this.cockpitView?.signalUserCollapsed,
layoutCockpit: () => this.cockpitView?.scheduleContextLayout(),
// An open local receiver keeps the shared Radio panel expanded.
preservePanelStateDuringClear: () =>
this._preservePanelStateDuringLayerClear,
this._preservePanelStateDuringLayerClear ||
Boolean(this._localSdrControls?.isActive()),
scheduleLayout: () => this._scheduleRightPanelLayout(),
},
});
this._localSdrControls?.destroy();
this._localSdrControls = null;
const receiver = this.services.localAdsbLayer?.receiver;
if (receiver) {
this._localSdrControls = new LocalSdrControls({
document,
receiver,
feeds: this.services.localAdsbLayer?.feeds || null,
radio: radioLayer,
actions: {
isLocalAdsbEnabled: () =>
Boolean(this._dataManager?.isEnabled('local-adsb')),
setLocalAdsbEnabled: (enabled) =>
this._dataManager?.setEnabled('local-adsb', enabled, {
origin: 'user',
}),
scheduleLayout: () => this._scheduleRightPanelLayout(),
},
});
}
}
/**
@@ -1479,6 +1504,7 @@ export class StyleManager extends ShellFacade {
this._clearLayersControl?.destroy();
this._cctvControls?.destroy();
this._radioControls?.destroy();
this._localSdrControls?.destroy();
this._cockpitCoordinator.stop();
this._visualSettings.stop();
this.shareLinkManager?.destroy();
+1
View File
@@ -23,6 +23,7 @@ const PANEL_GROUPS = [
'satellites',
'flights',
'military',
'local-adsb',
'ais-live-vessels',
'traffic',
'transit',
+1
View File
@@ -21,6 +21,7 @@ test('panel presentation places Transit between Street Traffic and Bike Share in
'satellites',
'flights',
'military',
'local-adsb',
'ais-live-vessels',
'traffic',
'transit',
+297
View File
@@ -0,0 +1,297 @@
import {
internetRadioAudioActive,
localSdrCardView,
localSdrFeedLine,
localSdrFmAudioActive,
} from './localSdrPresentation.js';
const ELEMENT_IDS = Object.freeze({
card: 'sdr-radio-card',
connectionState: 'sdr-connection-state',
connect: 'sdr-connect-btn',
locate: 'sdr-locate-btn',
changeDevice: 'sdr-change-device-btn',
modeFm: 'sdr-mode-fm-btn',
modeAdsb: 'sdr-mode-adsb-btn',
gain: 'sdr-gain-select',
stats: 'sdr-adsb-stats',
statRate: 'sdr-stat-rate',
statHeard: 'sdr-stat-heard',
statPositioned: 'sdr-stat-positioned',
statIq: 'sdr-stat-iq',
frequency: 'sdr-frequency-input',
tune: 'sdr-tune-btn',
seekBack: 'sdr-seek-back-btn',
seekForward: 'sdr-seek-forward-btn',
volume: 'sdr-volume',
volumeValue: 'sdr-volume-value',
status: 'sdr-status',
feedStatus: 'sdr-feed-status',
});
function setText(element, text) {
if (element && element.textContent !== text) element.textContent = text;
}
/**
* Local RTL-SDR card in the Radio panel.
*
* Owns the card's listeners and its receiver/radio subscriptions. The Radio
* panel is one listening surface: starting local FM stops internet radio, and
* starting internet radio stops local FM.
*/
export class LocalSdrControls {
/**
* @param {object} options
* @param {Document} options.document Document containing the card.
* @param {object} options.receiver Local RTL-SDR session.
* @param {object} [options.feeds] Decoder-feed session (getState,
* subscribe, probe); the card shows one read-only line when the server
* has feeds configured.
* @param {object} [options.radio] Internet-radio port (subscribe, stopPlayback).
* @param {object} [options.actions] Application actions:
* setLocalAdsbEnabled(enabled), isLocalAdsbEnabled(), scheduleLayout().
*/
constructor({
document: doc,
receiver,
feeds = null,
radio = null,
actions = {},
}) {
this.receiver = receiver;
this.feeds = feeds;
this.radio = radio;
this.actions = actions;
this.elements = Object.fromEntries(
Object.entries(ELEMENT_IDS).map(([key, id]) => [
key,
doc?.getElementById?.(id) || null,
]),
);
this._listeners = new AbortController();
this._state = receiver.getState();
this._radioState = null;
this._fmAudioActive = localSdrFmAudioActive(this._state);
this._radioAudioActive = false;
this._renderFrame = null;
this._destroyed = false;
if (!this.elements.card) return;
this._bind();
this._receiverUnsubscribe = receiver.subscribe((state) =>
this._onReceiverState(state),
);
this._radioUnsubscribe =
radio?.subscribe?.((state) => this._onRadioState(state)) || null;
this._feedsUnsubscribe =
feeds?.subscribe?.(() => this._scheduleRender()) || null;
// One request, no polling: learn whether the server has decoder feeds.
void feeds?.probe?.()?.catch?.(() => {});
this._render();
}
/** Whether the receiver session is open (keeps the Radio panel expanded). */
isActive() {
return Boolean(this._state?.connected);
}
_listen(element, type, handler) {
element?.addEventListener(type, handler, {
signal: this._listeners.signal,
});
}
_stopInternetRadio() {
if (internetRadioAudioActive(this._radioState))
this.radio?.stopPlayback?.({ origin: 'user' });
}
async _enableLayerWhenReceiving() {
const state = this.receiver.getState();
if (state.connected && state.mode === 'adsb')
await this.actions.setLocalAdsbEnabled?.(true);
}
_bind() {
const el = this.elements;
this._listen(el.connect, 'click', async () => {
const before = this.receiver.getState();
if (before.connected) {
await this.receiver.stop();
return;
}
if (before.mode === 'fm') this._stopInternetRadio();
if (await this.receiver.connect(before.mode))
await this._enableLayerWhenReceiving();
});
this._listen(el.locate, 'click', () => {
void this.receiver.requestReceiverLocation();
});
this._listen(el.changeDevice, 'click', async () => {
if (this.receiver.getState().mode === 'fm') this._stopInternetRadio();
if (await this.receiver.changeDevice())
await this._enableLayerWhenReceiving();
});
this._listen(el.modeFm, 'click', async () => {
this._stopInternetRadio();
if (this.actions.isLocalAdsbEnabled?.())
await this.actions.setLocalAdsbEnabled?.(false);
await this.receiver.setMode('fm');
});
this._listen(el.modeAdsb, 'click', async () => {
if (await this.receiver.setMode('adsb'))
await this._enableLayerWhenReceiving();
});
this._listen(el.gain, 'change', () => {
void this.receiver.setGain(el.gain.value);
});
const tune = () => {
const mhz = Number(el.frequency?.value);
if (!Number.isFinite(mhz)) return;
this._stopInternetRadio();
void this.receiver.tuneFm(mhz * 1_000_000);
};
this._listen(el.tune, 'click', tune);
this._listen(el.frequency, 'keydown', (event) => {
if (event.key !== 'Enter') return;
event.preventDefault();
tune();
});
for (const [button, direction] of [
[el.seekBack, -1],
[el.seekForward, 1],
]) {
this._listen(button, 'click', () => {
this._stopInternetRadio();
void this.receiver.seekFm(direction);
});
}
this._listen(el.volume, 'input', () => {
const value = Number(el.volume.value);
this.receiver.setVolume(value / 100);
setText(el.volumeValue, `${value}%`);
});
}
_onReceiverState(state) {
this._state = state;
const fmAudioActive = localSdrFmAudioActive(state);
// Local FM just started: it takes over from internet radio.
if (fmAudioActive && !this._fmAudioActive) this._stopInternetRadio();
this._fmAudioActive = fmAudioActive;
this._scheduleRender();
}
_onRadioState(state) {
this._radioState = state;
const radioAudioActive = internetRadioAudioActive(state);
// Internet radio just started: it takes over from local FM.
if (radioAudioActive && !this._radioAudioActive && this._fmAudioActive) {
void this.receiver.stop({
message: 'Local FM stopped: internet radio started',
});
}
this._radioAudioActive = radioAudioActive;
}
_scheduleRender() {
if (this._destroyed || this._renderFrame !== null) return;
const schedule =
typeof requestAnimationFrame === 'function'
? requestAnimationFrame
: (callback) => setTimeout(callback, 16);
this._renderFrame = schedule(() => {
this._renderFrame = null;
if (!this._destroyed) this._render();
});
}
_render() {
const el = this.elements;
const view = localSdrCardView(this._state);
const doc = el.card?.ownerDocument;
setText(el.connectionState, view.connectionLabel);
el.connectionState?.classList.toggle('active', view.connectionActive);
if (el.connect) {
setText(el.connect, view.connectLabel);
el.connect.disabled = view.connectDisabled;
el.connect.classList.toggle('active', view.connectPressed);
el.connect.setAttribute('aria-pressed', String(view.connectPressed));
}
for (const [button, active] of [
[el.modeFm, view.fmActive],
[el.modeAdsb, view.adsbActive],
]) {
if (!button) continue;
button.classList.toggle('active', active);
button.setAttribute('aria-pressed', String(active));
button.disabled = view.modeDisabled;
}
if (el.locate) {
setText(el.locate, view.locateLabel);
el.locate.disabled = view.locateDisabled;
el.locate.classList.toggle('active', view.locateActive);
}
if (el.changeDevice) el.changeDevice.disabled = view.changeDeviceDisabled;
if (el.gain) {
if (doc?.activeElement !== el.gain && el.gain.value !== view.gainValue)
el.gain.value = view.gainValue;
el.gain.disabled = view.gainDisabled;
}
if (el.stats) el.stats.hidden = view.statsHidden;
setText(el.statRate, view.stats.rate);
setText(el.statHeard, view.stats.heard);
setText(el.statPositioned, view.stats.positioned);
setText(el.statIq, view.stats.iq);
if (el.frequency) {
el.frequency.disabled = view.frequencyDisabled;
if (doc?.activeElement !== el.frequency && view.frequencyValue !== null)
el.frequency.value = view.frequencyValue;
}
if (el.tune) el.tune.disabled = view.tuneDisabled;
if (el.seekBack) el.seekBack.disabled = view.seekDisabled;
if (el.seekForward) el.seekForward.disabled = view.seekDisabled;
if (el.volume) {
if (doc?.activeElement !== el.volume)
el.volume.value = String(view.volumePercent);
el.volume.disabled = view.volumeDisabled;
}
setText(el.volumeValue, `${view.volumePercent}%`);
if (el.status) {
setText(el.status, view.statusText);
el.status.classList.toggle('error', view.statusError);
}
const feedLine = localSdrFeedLine(this.feeds?.getState?.() || null);
if (el.feedStatus) {
el.feedStatus.hidden = feedLine === null;
setText(el.feedStatus, feedLine || '');
}
el.card
?.closest?.('#radio-panel')
?.classList.toggle('sdr-connected', view.connectionActive);
// Only structural changes move the rail; per-block text updates do not.
const layoutKey = `${view.statsHidden}|${view.connectionActive}|${feedLine === null}`;
if (layoutKey !== this._layoutKey) {
this._layoutKey = layoutKey;
this.actions.scheduleLayout?.();
}
}
/** Release listeners and subscriptions; the receiver session is not closed. */
destroy() {
this._destroyed = true;
this._listeners.abort();
this._receiverUnsubscribe?.();
this._radioUnsubscribe?.();
this._feedsUnsubscribe?.();
this._feedsUnsubscribe = null;
this._receiverUnsubscribe = null;
this._radioUnsubscribe = null;
if (this._renderFrame !== null) {
if (typeof cancelAnimationFrame === 'function')
cancelAnimationFrame(this._renderFrame);
else clearTimeout(this._renderFrame);
this._renderFrame = null;
}
}
}
+207
View File
@@ -0,0 +1,207 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { LocalSdrControls } from './localSdrControls.js';
function stubDocument() {
const elements = new Map();
const doc = {
activeElement: null,
getElementById(id) {
if (!elements.has(id)) {
const listeners = new Map();
elements.set(id, {
id,
value: '',
hidden: false,
disabled: false,
textContent: '',
ownerDocument: doc,
classList: { toggle() {} },
setAttribute() {},
closest: () => null,
addEventListener(type, handler) {
listeners.set(type, handler);
},
fire: (type, event = {}) => listeners.get(type)?.(event),
});
}
return elements.get(id);
},
};
return doc;
}
function stubReceiver() {
const listeners = new Set();
const calls = [];
let state = {
webUsbSupported: true,
connected: false,
status: 'idle',
message: 'Connect an RTL-SDR to begin.',
mode: 'fm',
frequencyHz: 98_500_000,
volume: 0.8,
gain: 'auto',
locationStatus: 'unknown',
};
return {
calls,
getState: () => ({ ...state }),
subscribe(listener) {
listeners.add(listener);
listener({ ...state });
return () => listeners.delete(listener);
},
emit(patch) {
state = { ...state, ...patch };
for (const listener of listeners) listener({ ...state });
},
async stop(options) {
calls.push(['stop', options]);
this.emit({ connected: false, status: 'idle' });
return true;
},
async setMode(mode) {
calls.push(['setMode', mode]);
this.emit({ mode });
return true;
},
async setGain(value) {
calls.push(['setGain', value]);
return value;
},
async tuneFm(hz) {
calls.push(['tuneFm', hz]);
return true;
},
};
}
function stubRadio() {
const listeners = new Set();
const calls = [];
return {
calls,
subscribe(listener) {
listeners.add(listener);
listener({ audioState: 'idle' });
return () => listeners.delete(listener);
},
emit(state) {
for (const listener of listeners) listener(state);
},
stopPlayback(options) {
calls.push(options);
this.emit({ audioState: 'idle' });
},
};
}
test('starting internet radio stops local FM, and local FM stops internet radio', async (t) => {
const receiver = stubReceiver();
const radio = stubRadio();
const controls = new LocalSdrControls({
document: stubDocument(),
receiver,
radio,
});
t.after(() => controls.destroy());
receiver.emit({ connected: true, status: 'streaming', mode: 'fm' });
assert.equal(radio.calls.length, 0, 'nothing to stop while radio is silent');
radio.emit({ audioState: 'loading' });
assert.deepEqual(receiver.calls.at(-1), [
'stop',
{ message: 'Local FM stopped: internet radio started' },
]);
radio.emit({ audioState: 'playing' });
receiver.emit({ connected: true, status: 'streaming', mode: 'fm' });
assert.deepEqual(radio.calls, [{ origin: 'user' }]);
});
test('ADS-B reception and internet radio play together', async (t) => {
const receiver = stubReceiver();
const radio = stubRadio();
const controls = new LocalSdrControls({
document: stubDocument(),
receiver,
radio,
});
t.after(() => controls.destroy());
receiver.emit({ connected: true, status: 'streaming', mode: 'adsb' });
radio.emit({ audioState: 'playing' });
assert.equal(receiver.calls.length, 0);
assert.equal(radio.calls.length, 0);
});
test('card controls drive gain, FM mode and the Local ADS-B layer', async (t) => {
const doc = stubDocument();
const receiver = stubReceiver();
const layerCalls = [];
const controls = new LocalSdrControls({
document: doc,
receiver,
actions: {
isLocalAdsbEnabled: () => true,
setLocalAdsbEnabled: async (enabled) => layerCalls.push(enabled),
},
});
t.after(() => controls.destroy());
const gain = doc.getElementById('sdr-gain-select');
gain.value = '20.7';
gain.fire('change');
assert.deepEqual(receiver.calls.at(-1), ['setGain', '20.7']);
receiver.emit({ connected: true, status: 'streaming', mode: 'adsb' });
await doc.getElementById('sdr-mode-fm-btn').fire('click');
assert.deepEqual(layerCalls, [false]);
assert.deepEqual(receiver.calls.at(-1), ['setMode', 'fm']);
await doc.getElementById('sdr-mode-adsb-btn').fire('click');
assert.deepEqual(layerCalls, [false, true]);
assert.equal(controls.isActive(), true);
});
test('the card shows one read-only decoder-feed line after a single probe', async (t) => {
const doc = stubDocument();
const listeners = new Set();
let probes = 0;
let state = { configured: null, polling: false, feeds: [] };
const feeds = {
getState: () => state,
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
async probe() {
probes += 1;
},
set(patch) {
state = { ...state, ...patch };
for (const listener of listeners) listener(state);
},
};
const controls = new LocalSdrControls({
document: doc,
receiver: stubReceiver(),
feeds,
});
t.after(() => controls.destroy());
const line = doc.getElementById('sdr-feed-status');
assert.equal(probes, 1);
assert.equal(line.hidden, true);
feeds.set({
configured: true,
polling: true,
feeds: [
{ band: '1090', status: 'live' },
{ band: '978', status: 'live' },
],
});
await new Promise((resolve) => setTimeout(resolve, 30));
assert.equal(line.hidden, false);
assert.equal(line.textContent, 'Decoder feeds: 1090 live · 978 live');
});
+75
View File
@@ -0,0 +1,75 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { expandApplicationHtml } from '../../build/application-html.js';
import { createBrowserViteConfig } from '../../build/vite.js';
import { R820T_GAIN_STEPS_DB } from '../sdr/gain.js';
const html = expandApplicationHtml(
readFileSync(new URL('../../index.html', import.meta.url), 'utf8'),
);
const css = readFileSync(
new URL('./styles/radio.css', import.meta.url),
'utf8',
);
test('the Local RTL-SDR card lives inside the Radio panel with every control', () => {
const radioStart = html.indexOf('id="radio-panel"');
const cardStart = html.indexOf('id="sdr-radio-card"');
const radioEnd = html.indexOf('</section>', radioStart);
assert.ok(radioStart >= 0 && cardStart > radioStart && cardStart < radioEnd);
for (const id of [
'sdr-connection-state',
'sdr-connect-btn',
'sdr-locate-btn',
'sdr-change-device-btn',
'sdr-mode-fm-btn',
'sdr-mode-adsb-btn',
'sdr-gain-select',
'sdr-adsb-stats',
'sdr-stat-rate',
'sdr-stat-heard',
'sdr-stat-positioned',
'sdr-stat-iq',
'sdr-frequency-input',
'sdr-tune-btn',
'sdr-seek-back-btn',
'sdr-seek-forward-btn',
'sdr-volume',
'sdr-status',
'sdr-feed-status',
]) {
assert.match(html, new RegExp(`id="${id}"`), `${id} is missing`);
}
assert.match(html, /id="sdr-status"[^>]*>Connect an RTL-SDR to begin\.</);
assert.match(css, /\.sdr-radio-card\s*\{/);
});
test('the gain control offers AUTO plus every R820T step', () => {
const start = html.indexOf('id="sdr-gain-select"');
const select = html.slice(start, html.indexOf('</select>', start));
const values = [...select.matchAll(/<option value="([^"]+)"/g)].map(
(match) => match[1],
);
assert.deepEqual(values, [
'auto',
...R820T_GAIN_STEPS_DB.map((step) => step.toFixed(1)),
]);
});
test('worker-reached SDR dependencies are pre-bundled and builds keep a separate cache', () => {
const serve = createBrowserViteConfig();
for (const dependency of [
'@jtarrio/signals/demod/demodulator.js',
'@jtarrio/signals/demod/modes.js',
'@jtarrio/webrtlsdr/rtlsdr.js',
]) {
assert.ok(serve.optimizeDeps.include.includes(dependency), dependency);
}
assert.equal(serve.cacheDir, undefined);
assert.equal(
createBrowserViteConfig({ command: 'build' }).cacheDir,
'node_modules/.vite-build',
);
});
+146
View File
@@ -0,0 +1,146 @@
import { localReceiverFeedName } from '../layers/localAdsb/feedNames.js';
/**
* Pure presentation of the Local RTL-SDR card in the Radio panel. Receives the
* receiver session snapshot and returns what each control should show.
*/
function formatGain(gain) {
return gain === 'auto' || !Number.isFinite(gain) ? 'auto' : gain.toFixed(1);
}
function formatIq(state) {
return Number.isFinite(state.iqLevelDbfs)
? `${state.iqLevelDbfs.toFixed(1)} dBFS`
: '--';
}
function statusText(state) {
const fmActive = state.mode === 'fm';
const iqStatus =
state.samplesPerSecond > 0
? `${(state.samplesPerSecond / 1_000_000).toFixed(2)} MS/s IQ`
: 'waiting for IQ';
const device = state.deviceLabel ? ` · ${state.deviceLabel}` : '';
if (state.connected && !fmActive) {
return `${state.message}${device} · ${iqStatus} · ${state.decodedMessages} messages`;
}
const detail = state.seeking
? state.seekMessage
: state.seekMessage || state.message;
if (state.connected && fmActive) {
const workerStatus =
state.workerBlocks > 0
? `DSP ${state.workerBlocks} blocks`
: 'DSP waiting';
const rfStatus = `RF ${formatIq(state)}`;
const audioSignal = Number.isFinite(state.audioLevelDbfs)
? `${state.audioLevelDbfs.toFixed(1)} dBFS audio`
: 'audio signal --';
return `${detail}${device} · ${iqStatus} · ${workerStatus} · ${rfStatus} · ${audioSignal} · audio ${state.audioState}`;
}
return detail;
}
/**
* Derive the card's control states from one receiver snapshot.
* @param {object} state SdrController.getState() snapshot.
* @returns {object} Control presentation.
*/
export function localSdrCardView(state) {
const transitional =
state.status === 'connecting' || state.status === 'tuning';
const fmActive = state.mode === 'fm';
const adsbActive = state.mode === 'adsb';
const fmInteractive =
state.connected && fmActive && state.status === 'streaming';
return {
connectionLabel: state.connected
? adsbActive
? `${state.aircraftHeard || 0} HEARD`
: 'STREAMING'
: String(state.status || 'idle').toUpperCase(),
connectionActive: Boolean(state.connected),
connectLabel: transitional
? String(state.status).toUpperCase()
: state.connected
? 'DISCONNECT'
: 'CONNECT',
connectDisabled: transitional || !state.webUsbSupported,
connectPressed: Boolean(state.connected),
fmActive,
adsbActive,
modeDisabled: transitional,
locateLabel:
state.locationStatus === 'requesting'
? 'LOCATING…'
: state.locationStatus === 'ready'
? 'LOCATED'
: 'LOCATE',
locateDisabled: state.locationStatus === 'requesting',
locateActive: state.locationStatus === 'ready',
changeDeviceDisabled: transitional || !state.webUsbSupported,
gainValue: formatGain(state.gain),
gainDisabled: transitional,
statsHidden: !(adsbActive && state.connected),
stats: {
rate: Number.isFinite(state.messagesPerSecond)
? state.messagesPerSecond.toFixed(1)
: '--',
heard: String(state.aircraftHeard || 0),
positioned: String(state.aircraftPositioned || 0),
iq: formatIq(state),
},
frequencyDisabled: !fmActive || transitional,
frequencyValue: fmActive
? (state.frequencyHz / 1_000_000).toFixed(1)
: null,
tuneDisabled: !state.connected || !fmActive || transitional,
seekDisabled: !fmInteractive || Boolean(state.seeking),
volumePercent: Math.round((Number(state.volume) || 0) * 100),
volumeDisabled: !fmActive,
statusText: statusText(state),
statusError: state.status === 'error' || state.status === 'unsupported',
};
}
/**
* Whether the receiver is producing FM audio (the state that must not overlap
* internet-radio playback).
* @param {object} state SdrController.getState() snapshot.
* @returns {boolean}
*/
export function localSdrFmAudioActive(state) {
return Boolean(
state?.connected && state.mode === 'fm' && state.status === 'streaming',
);
}
/**
* Whether internet radio is loading or playing a station.
* @param {object} state Radio UI state.
* @returns {boolean}
*/
export function internetRadioAudioActive(state) {
return ['loading', 'buffering', 'playing'].includes(state?.audioState);
}
/**
* One-line decoder-feed summary for the card, or null when the server has no
* feed configured. Statuses show while Local ADS-B polls the feeds; otherwise
* the line names the configured bands.
* @param {object|null} feedState Local receiver feed session snapshot.
* @returns {string|null}
*/
export function localSdrFeedLine(feedState) {
const feeds = feedState?.configured === true ? feedState.feeds : null;
if (!Array.isArray(feeds) || !feeds.length) return null;
const names = feeds.map((feed) => {
const name = localReceiverFeedName(feed, feeds);
return feedState.polling || feed?.status === 'invalid'
? `${name} ${feed?.status || 'unreachable'}`
: name;
});
const suffix = feedState.polling ? '' : ' · read while Local ADS-B is on';
return `Decoder feeds: ${names.join(' · ')}${suffix}`;
}
+155
View File
@@ -0,0 +1,155 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
internetRadioAudioActive,
localSdrCardView,
localSdrFeedLine,
localSdrFmAudioActive,
} from './localSdrPresentation.js';
const idle = Object.freeze({
webUsbSupported: true,
connected: false,
status: 'idle',
message: 'Connect an RTL-SDR to begin.',
mode: 'fm',
frequencyHz: 98_500_000,
volume: 0.8,
gain: 'auto',
seeking: false,
seekMessage: '',
decodedMessages: 0,
messagesPerSecond: null,
aircraftHeard: 0,
aircraftPositioned: 0,
deviceLabel: null,
locationStatus: 'unknown',
audioState: 'idle',
samplesPerSecond: 0,
workerBlocks: 0,
iqLevelDbfs: null,
audioLevelDbfs: null,
});
test('an idle card invites a connection and hides receiver stats', () => {
const view = localSdrCardView(idle);
assert.equal(view.statusText, 'Connect an RTL-SDR to begin.');
assert.equal(view.connectLabel, 'CONNECT');
assert.equal(view.connectDisabled, false);
assert.equal(view.connectionLabel, 'IDLE');
assert.equal(view.gainValue, 'auto');
assert.equal(view.statsHidden, true);
assert.equal(view.tuneDisabled, true);
assert.equal(view.seekDisabled, true);
assert.equal(view.frequencyValue, '98.5');
const unsupported = localSdrCardView({
...idle,
webUsbSupported: false,
status: 'unsupported',
message: 'WebUSB is unavailable in this browser',
});
assert.equal(unsupported.connectDisabled, true);
assert.equal(unsupported.changeDeviceDisabled, true);
assert.equal(unsupported.statusError, true);
});
test('streaming ADS-B shows message rate, heard, positioned and IQ level', () => {
const view = localSdrCardView({
...idle,
connected: true,
status: 'streaming',
mode: 'adsb',
message: 'Receiving 1090 MHz ADS-B',
gain: 20.7,
messagesPerSecond: 13.8,
aircraftHeard: 4,
aircraftPositioned: 2,
iqLevelDbfs: -21.44,
samplesPerSecond: 2_000_000,
decodedMessages: 276,
deviceLabel: 'FlyCatcher_ADS_B',
});
assert.equal(view.statsHidden, false);
assert.deepEqual(view.stats, {
rate: '13.8',
heard: '4',
positioned: '2',
iq: '-21.4 dBFS',
});
assert.equal(view.gainValue, '20.7');
assert.equal(view.connectionLabel, '4 HEARD');
assert.equal(view.connectLabel, 'DISCONNECT');
assert.equal(view.frequencyDisabled, true);
assert.equal(view.volumeDisabled, true);
assert.equal(
view.statusText,
'Receiving 1090 MHz ADS-B · FlyCatcher_ADS_B · 2.00 MS/s IQ · 276 messages',
);
});
test('local FM and internet radio audio states drive the one-listener rule', () => {
assert.equal(localSdrFmAudioActive(idle), false);
assert.equal(
localSdrFmAudioActive({ ...idle, connected: true, status: 'streaming' }),
true,
);
assert.equal(
localSdrFmAudioActive({
...idle,
connected: true,
status: 'streaming',
mode: 'adsb',
}),
false,
'ADS-B produces no audio',
);
for (const audioState of ['loading', 'buffering', 'playing'])
assert.equal(internetRadioAudioActive({ audioState }), true);
assert.equal(internetRadioAudioActive({ audioState: 'paused' }), false);
assert.equal(internetRadioAudioActive(null), false);
});
test('the decoder-feed line appears only when feeds are configured', () => {
assert.equal(localSdrFeedLine(null), null);
assert.equal(
localSdrFeedLine({ configured: false, feeds: [], polling: false }),
null,
);
const feeds = [
{ band: '1090', label: '1090 MHz', status: 'live' },
{ band: '978', label: '978 MHz UAT', status: 'unreachable' },
];
assert.equal(
localSdrFeedLine({ configured: true, polling: true, feeds }),
'Decoder feeds: 1090 live · 978 unreachable',
);
assert.equal(
localSdrFeedLine({ configured: true, polling: false, feeds }),
'Decoder feeds: 1090 · 978 · read while Local ADS-B is on',
);
assert.equal(
localSdrFeedLine({
configured: true,
polling: false,
feeds: [{ band: null, label: 'entry 2', status: 'invalid' }],
}),
'Decoder feeds: entry 2 invalid · read while Local ADS-B is on',
);
});
test('two feeds on one band are told apart by their ordinal labels', () => {
const feeds = [
{ band: '1090', label: '1090 MHz', status: 'live' },
{ band: '978', label: '978 MHz UAT #1', status: 'live' },
{ band: '978', label: '978 MHz UAT #2', status: 'stale' },
];
assert.equal(
localSdrFeedLine({ configured: true, polling: true, feeds }),
'Decoder feeds: 1090 live · 978 MHz UAT #1 live · 978 MHz UAT #2 stale',
);
assert.equal(
localSdrFeedLine({ configured: true, polling: false, feeds }),
'Decoder feeds: 1090 · 978 MHz UAT #1 · 978 MHz UAT #2 · read while Local ADS-B is on',
);
});
+129
View File
@@ -899,3 +899,132 @@
padding-top: 3px;
overflow-wrap: anywhere;
}
/* ── Local RTL-SDR card (FM + Local ADS-B receiver) ── */
.sdr-radio-card {
display: grid;
gap: 7px;
margin-top: 3px;
padding: 9px;
border: 1px solid rgba(255, 170, 60, 0.28);
border-radius: 7px;
background: linear-gradient(
180deg,
rgba(31, 18, 4, 0.58),
rgba(4, 20, 24, 0.72)
);
}
.sdr-radio-heading,
.sdr-radio-actions,
.sdr-mode-row,
.sdr-gain-row,
.sdr-frequency-row,
.sdr-seek-row {
display: flex;
align-items: center;
gap: 6px;
}
.sdr-radio-heading {
justify-content: space-between;
}
.sdr-radio-heading strong {
color: #ffd38a;
font: 9px/1 var(--font-mono);
letter-spacing: 0.1em;
}
#sdr-connection-state {
color: #9e865f;
font: 7px/1 var(--font-mono);
letter-spacing: 0.1em;
}
#sdr-connection-state.active {
color: #74f7c4;
}
.sdr-radio-card > p {
margin: 0;
color: rgba(213, 230, 225, 0.58);
font: 7px/1.4 var(--font-mono);
}
.sdr-radio-card button,
.sdr-radio-card select,
.sdr-radio-card input[type='number'] {
min-height: 28px;
border: 1px solid rgba(255, 183, 82, 0.28);
border-radius: 5px;
color: #ffd79a;
background: rgba(38, 25, 8, 0.52);
font: 8px/1 var(--font-mono);
}
.sdr-radio-card button {
padding: 6px 8px;
cursor: pointer;
}
.sdr-radio-card button:hover:not(:disabled),
.sdr-radio-card button:focus-visible:not(:disabled),
.sdr-radio-card button.active {
border-color: #ffd38a;
color: #fff;
outline: none;
background: rgba(130, 83, 18, 0.28);
}
.sdr-radio-card button:disabled,
.sdr-radio-card select:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.sdr-radio-actions > *,
.sdr-mode-row > *,
.sdr-seek-row > * {
flex: 1;
}
.sdr-gain-row label,
.sdr-frequency-row label {
color: #9e865f;
font: 7px/1 var(--font-mono);
letter-spacing: 0.08em;
}
.sdr-gain-row select {
flex: 1;
min-width: 0;
padding: 5px 7px;
}
.sdr-adsb-stats {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 4px 8px;
color: #9e865f;
font: 7px/1.3 var(--font-mono);
letter-spacing: 0.08em;
}
.sdr-adsb-stats[hidden] {
display: none;
}
.sdr-adsb-stats output {
color: #ffd79a;
font: 9px/1 var(--font-mono);
}
.sdr-frequency-row input[type='number'] {
width: 78px;
min-width: 0;
padding: 5px 7px;
}
.sdr-frequency-row button {
margin-left: auto;
}
.sdr-radio-card .radio-volume-row {
color: #9e865f;
}
.sdr-radio-card .radio-playback-state {
color: #ddbe86;
}
.sdr-radio-card .radio-playback-state.error {
color: #ff9d8f;
}
.sdr-feed-status {
color: #9e865f;
font: 8px/1.3 var(--font-mono);
letter-spacing: 0.08em;
overflow-wrap: anywhere;
}
.sdr-feed-status[hidden] {
display: none;
}
+74 -1
View File
@@ -109,7 +109,7 @@
</div>
</div>
</section>
<section id="radio-panel" class="panel-collapsible collapsed" data-panel-id="radio-panel" aria-label="Internet radio companion">
<section id="radio-panel" class="panel-collapsible collapsed" data-panel-id="radio-panel" aria-label="Internet radio and local SDR">
<div class="radio-panel-inner">
<div class="panel-header">
<span class="panel-title">RADIO</span>
@@ -162,6 +162,79 @@
<a href="https://www.radio-browser.info/" target="_blank" rel="noopener noreferrer">DIRECTORY: RADIO BROWSER</a>
</div>
<p class="radio-privacy">Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.</p>
<div id="sdr-radio-card" class="sdr-radio-card" role="group" aria-label="Local RTL-SDR receiver">
<div class="sdr-radio-heading">
<strong>LOCAL RTL-SDR</strong>
<span id="sdr-connection-state">NO USB</span>
</div>
<p>Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.</p>
<div class="sdr-radio-actions">
<button id="sdr-connect-btn" class="panel-layer-toggle" type="button" aria-pressed="false">CONNECT</button>
<button id="sdr-locate-btn" type="button" title="Use your location for faster ADS-B position decoding">LOCATE</button>
<button id="sdr-change-device-btn" type="button" title="Choose a different RTL-SDR or receiver channel">CHANGE DEVICE</button>
</div>
<div class="sdr-mode-row" role="group" aria-label="RTL-SDR receiver mode">
<button id="sdr-mode-fm-btn" class="active" type="button" aria-pressed="true">FM</button>
<button id="sdr-mode-adsb-btn" type="button" aria-pressed="false">ADS-B · 1090</button>
</div>
<div class="sdr-gain-row">
<label for="sdr-gain-select">GAIN</label>
<select id="sdr-gain-select" aria-label="RTL-SDR tuner gain for the current mode">
<option value="auto">AUTO</option>
<option value="0.0">0.0 dB</option>
<option value="0.9">0.9 dB</option>
<option value="1.4">1.4 dB</option>
<option value="2.7">2.7 dB</option>
<option value="3.7">3.7 dB</option>
<option value="7.7">7.7 dB</option>
<option value="8.7">8.7 dB</option>
<option value="12.5">12.5 dB</option>
<option value="14.4">14.4 dB</option>
<option value="15.7">15.7 dB</option>
<option value="16.6">16.6 dB</option>
<option value="19.7">19.7 dB</option>
<option value="20.7">20.7 dB</option>
<option value="22.9">22.9 dB</option>
<option value="25.4">25.4 dB</option>
<option value="28.0">28.0 dB</option>
<option value="29.7">29.7 dB</option>
<option value="32.8">32.8 dB</option>
<option value="33.8">33.8 dB</option>
<option value="36.4">36.4 dB</option>
<option value="37.2">37.2 dB</option>
<option value="38.6">38.6 dB</option>
<option value="40.2">40.2 dB</option>
<option value="42.1">42.1 dB</option>
<option value="43.4">43.4 dB</option>
<option value="43.9">43.9 dB</option>
<option value="44.5">44.5 dB</option>
<option value="48.0">48.0 dB</option>
<option value="49.6">49.6 dB</option>
</select>
</div>
<div id="sdr-adsb-stats" class="sdr-adsb-stats" role="status" aria-live="off" hidden>
<span><output id="sdr-stat-rate">--</output> MSG/S</span>
<span><output id="sdr-stat-heard">0</output> HEARD</span>
<span><output id="sdr-stat-positioned">0</output> POSITIONED</span>
<span>IQ <output id="sdr-stat-iq">--</output></span>
</div>
<div class="sdr-frequency-row">
<label for="sdr-frequency-input">FM MHZ</label>
<input id="sdr-frequency-input" type="number" min="87.5" max="108" step="0.1" value="98.5" inputmode="decimal" />
<button id="sdr-tune-btn" type="button">TUNE</button>
</div>
<div class="sdr-seek-row" role="group" aria-label="Seek broadcast FM stations">
<button id="sdr-seek-back-btn" type="button" disabled>◀ SEEK</button>
<button id="sdr-seek-forward-btn" type="button" disabled>SEEK ▶</button>
</div>
<label class="radio-volume-row" for="sdr-volume">
<span>SDR VOL</span>
<input id="sdr-volume" class="gev-quantitative-slider" type="range" min="0" max="100" value="80" aria-label="Local SDR volume" />
<output id="sdr-volume-value" class="gev-slider-value" for="sdr-volume">80%</output>
</label>
<div id="sdr-status" class="radio-playback-state" role="status" aria-live="polite">Connect an RTL-SDR to begin.</div>
<div id="sdr-feed-status" class="sdr-feed-status" role="status" aria-live="off" hidden></div>
</div>
</div>
</section>
</div>