mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
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:
78 files changed
+12267
-108
No files matched your search
@@ -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();
|
||||
|
||||
@@ -23,6 +23,7 @@ const PANEL_GROUPS = [
|
||||
'satellites',
|
||||
'flights',
|
||||
'military',
|
||||
'local-adsb',
|
||||
'ais-live-vessels',
|
||||
'traffic',
|
||||
'transit',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user