mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
Add DMR digital voice, WebSDR, and listening post enhancements
- DMR/P25 digital voice decoder mode with DSD-FME integration - WebSDR mode with KiwiSDR audio proxy and websocket-client support - Listening post waterfall/spectrogram visualization and audio streaming - Dockerfile updates for mbelib and DSD-FME build dependencies - New tests for DMR, WebSDR, KiwiSDR, waterfall, and signal guess API - Chart.js date adapter for time-scale axes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
200
static/js/modes/dmr.js
Normal file
200
static/js/modes/dmr.js
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Intercept - DMR / Digital Voice Mode
|
||||
* Decoding DMR, P25, NXDN, D-STAR digital voice protocols
|
||||
*/
|
||||
|
||||
// ============== STATE ==============
|
||||
let isDmrRunning = false;
|
||||
let dmrEventSource = null;
|
||||
let dmrCallCount = 0;
|
||||
let dmrSyncCount = 0;
|
||||
let dmrCallHistory = [];
|
||||
let dmrCurrentProtocol = '--';
|
||||
|
||||
// ============== TOOLS CHECK ==============
|
||||
|
||||
function checkDmrTools() {
|
||||
fetch('/dmr/tools')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const warning = document.getElementById('dmrToolsWarning');
|
||||
const warningText = document.getElementById('dmrToolsWarningText');
|
||||
if (!warning) return;
|
||||
|
||||
const missing = [];
|
||||
if (!data.dsd) missing.push('dsd (Digital Speech Decoder)');
|
||||
if (!data.rtl_fm) missing.push('rtl_fm (RTL-SDR)');
|
||||
|
||||
if (missing.length > 0) {
|
||||
warning.style.display = 'block';
|
||||
if (warningText) warningText.textContent = missing.join(', ');
|
||||
} else {
|
||||
warning.style.display = 'none';
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// ============== START / STOP ==============
|
||||
|
||||
function startDmr() {
|
||||
const frequency = parseFloat(document.getElementById('dmrFrequency')?.value || 462.5625);
|
||||
const protocol = document.getElementById('dmrProtocol')?.value || 'auto';
|
||||
const gain = parseInt(document.getElementById('dmrGain')?.value || 40);
|
||||
const device = typeof getSelectedDevice === 'function' ? getSelectedDevice() : 0;
|
||||
|
||||
fetch('/dmr/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ frequency, protocol, gain, device })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.status === 'started') {
|
||||
isDmrRunning = true;
|
||||
dmrCallCount = 0;
|
||||
dmrSyncCount = 0;
|
||||
dmrCallHistory = [];
|
||||
updateDmrUI();
|
||||
connectDmrSSE();
|
||||
const statusEl = document.getElementById('dmrStatus');
|
||||
if (statusEl) statusEl.textContent = 'DECODING';
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification('DMR', `Decoding ${frequency} MHz (${protocol.toUpperCase()})`);
|
||||
}
|
||||
} else {
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification('Error', data.message || 'Failed to start DMR');
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('[DMR] Start error:', err));
|
||||
}
|
||||
|
||||
function stopDmr() {
|
||||
fetch('/dmr/stop', { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(() => {
|
||||
isDmrRunning = false;
|
||||
if (dmrEventSource) { dmrEventSource.close(); dmrEventSource = null; }
|
||||
updateDmrUI();
|
||||
const statusEl = document.getElementById('dmrStatus');
|
||||
if (statusEl) statusEl.textContent = 'IDLE';
|
||||
})
|
||||
.catch(err => console.error('[DMR] Stop error:', err));
|
||||
}
|
||||
|
||||
// ============== SSE STREAMING ==============
|
||||
|
||||
function connectDmrSSE() {
|
||||
if (dmrEventSource) dmrEventSource.close();
|
||||
dmrEventSource = new EventSource('/dmr/stream');
|
||||
|
||||
dmrEventSource.onmessage = function(event) {
|
||||
const msg = JSON.parse(event.data);
|
||||
handleDmrMessage(msg);
|
||||
};
|
||||
|
||||
dmrEventSource.onerror = function() {
|
||||
if (isDmrRunning) {
|
||||
setTimeout(connectDmrSSE, 2000);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function handleDmrMessage(msg) {
|
||||
if (msg.type === 'sync') {
|
||||
dmrCurrentProtocol = msg.protocol || '--';
|
||||
const protocolEl = document.getElementById('dmrActiveProtocol');
|
||||
if (protocolEl) protocolEl.textContent = dmrCurrentProtocol;
|
||||
const mainProtocolEl = document.getElementById('dmrMainProtocol');
|
||||
if (mainProtocolEl) mainProtocolEl.textContent = dmrCurrentProtocol;
|
||||
dmrSyncCount++;
|
||||
const syncCountEl = document.getElementById('dmrSyncCount');
|
||||
if (syncCountEl) syncCountEl.textContent = dmrSyncCount;
|
||||
} else if (msg.type === 'call') {
|
||||
dmrCallCount++;
|
||||
const countEl = document.getElementById('dmrCallCount');
|
||||
if (countEl) countEl.textContent = dmrCallCount;
|
||||
const mainCountEl = document.getElementById('dmrMainCallCount');
|
||||
if (mainCountEl) mainCountEl.textContent = dmrCallCount;
|
||||
|
||||
// Update current call display
|
||||
const callEl = document.getElementById('dmrCurrentCall');
|
||||
if (callEl) {
|
||||
callEl.innerHTML = `
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span style="color: var(--text-muted);">Talkgroup</span>
|
||||
<span style="color: var(--accent-green); font-weight: bold; font-family: var(--font-mono);">${msg.talkgroup}</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span style="color: var(--text-muted);">Source ID</span>
|
||||
<span style="color: var(--accent-cyan); font-family: var(--font-mono);">${msg.source_id}</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between;">
|
||||
<span style="color: var(--text-muted);">Time</span>
|
||||
<span style="color: var(--text-primary);">${msg.timestamp}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Add to history
|
||||
dmrCallHistory.unshift({
|
||||
talkgroup: msg.talkgroup,
|
||||
source_id: msg.source_id,
|
||||
protocol: dmrCurrentProtocol,
|
||||
time: msg.timestamp,
|
||||
});
|
||||
if (dmrCallHistory.length > 50) dmrCallHistory.length = 50;
|
||||
renderDmrHistory();
|
||||
|
||||
} else if (msg.type === 'slot') {
|
||||
// Update slot info in current call
|
||||
} else if (msg.type === 'status') {
|
||||
const statusEl = document.getElementById('dmrStatus');
|
||||
if (statusEl) {
|
||||
statusEl.textContent = msg.text === 'started' ? 'DECODING' : 'IDLE';
|
||||
}
|
||||
if (msg.text === 'stopped') {
|
||||
isDmrRunning = false;
|
||||
updateDmrUI();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============== UI ==============
|
||||
|
||||
function updateDmrUI() {
|
||||
const startBtn = document.getElementById('startDmrBtn');
|
||||
const stopBtn = document.getElementById('stopDmrBtn');
|
||||
if (startBtn) startBtn.style.display = isDmrRunning ? 'none' : 'block';
|
||||
if (stopBtn) stopBtn.style.display = isDmrRunning ? 'block' : 'none';
|
||||
}
|
||||
|
||||
function renderDmrHistory() {
|
||||
const container = document.getElementById('dmrHistoryBody');
|
||||
if (!container) return;
|
||||
|
||||
const historyCountEl = document.getElementById('dmrHistoryCount');
|
||||
if (historyCountEl) historyCountEl.textContent = `${dmrCallHistory.length} calls`;
|
||||
|
||||
if (dmrCallHistory.length === 0) {
|
||||
container.innerHTML = '<tr><td colspan="4" style="padding: 10px; text-align: center; color: var(--text-muted);">No calls recorded</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = dmrCallHistory.slice(0, 20).map(call => `
|
||||
<tr>
|
||||
<td style="padding: 3px 6px; font-family: var(--font-mono);">${call.time}</td>
|
||||
<td style="padding: 3px 6px; color: var(--accent-green);">${call.talkgroup}</td>
|
||||
<td style="padding: 3px 6px; color: var(--accent-cyan);">${call.source_id}</td>
|
||||
<td style="padding: 3px 6px;">${call.protocol}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// ============== EXPORTS ==============
|
||||
|
||||
window.startDmr = startDmr;
|
||||
window.stopDmr = stopDmr;
|
||||
window.checkDmrTools = checkDmrTools;
|
||||
@@ -830,6 +830,11 @@ function handleSignalFound(data) {
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification('Signal Found!', `${freqStr} MHz - Audio streaming`);
|
||||
}
|
||||
|
||||
// Auto-trigger signal identification
|
||||
if (typeof guessSignal === 'function') {
|
||||
guessSignal(data.frequency, data.modulation);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSignalLost(data) {
|
||||
@@ -2937,6 +2942,281 @@ window.updateListenButtonState = updateListenButtonState;
|
||||
// Export functions for HTML onclick handlers
|
||||
window.toggleDirectListen = toggleDirectListen;
|
||||
window.startDirectListen = startDirectListen;
|
||||
// ============== SIGNAL IDENTIFICATION ==============
|
||||
|
||||
function guessSignal(frequencyMhz, modulation) {
|
||||
const body = { frequency_mhz: frequencyMhz };
|
||||
if (modulation) body.modulation = modulation;
|
||||
|
||||
return fetch('/listening/signal/guess', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.status === 'ok') {
|
||||
renderSignalGuess(data);
|
||||
}
|
||||
return data;
|
||||
})
|
||||
.catch(err => console.error('[SIGNAL-ID] Error:', err));
|
||||
}
|
||||
|
||||
function renderSignalGuess(result) {
|
||||
const panel = document.getElementById('signalGuessPanel');
|
||||
if (!panel) return;
|
||||
panel.style.display = 'block';
|
||||
|
||||
const label = document.getElementById('signalGuessLabel');
|
||||
const badge = document.getElementById('signalGuessBadge');
|
||||
const explanation = document.getElementById('signalGuessExplanation');
|
||||
const tagsEl = document.getElementById('signalGuessTags');
|
||||
const altsEl = document.getElementById('signalGuessAlternatives');
|
||||
|
||||
if (label) label.textContent = result.primary_label || 'Unknown';
|
||||
|
||||
if (badge) {
|
||||
badge.textContent = result.confidence || '';
|
||||
const colors = { 'HIGH': '#00e676', 'MEDIUM': '#ff9800', 'LOW': '#9e9e9e' };
|
||||
badge.style.background = colors[result.confidence] || '#9e9e9e';
|
||||
badge.style.color = '#000';
|
||||
}
|
||||
|
||||
if (explanation) explanation.textContent = result.explanation || '';
|
||||
|
||||
if (tagsEl) {
|
||||
tagsEl.innerHTML = (result.tags || []).map(tag =>
|
||||
`<span style="background: rgba(0,200,255,0.15); color: var(--accent-cyan); padding: 1px 6px; border-radius: 3px; font-size: 9px;">${tag}</span>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
if (altsEl) {
|
||||
if (result.alternatives && result.alternatives.length > 0) {
|
||||
altsEl.innerHTML = '<strong>Also:</strong> ' + result.alternatives.map(a =>
|
||||
`${a.label} <span style="color: ${a.confidence === 'HIGH' ? '#00e676' : a.confidence === 'MEDIUM' ? '#ff9800' : '#9e9e9e'}">(${a.confidence})</span>`
|
||||
).join(', ');
|
||||
} else {
|
||||
altsEl.innerHTML = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function manualSignalGuess() {
|
||||
const input = document.getElementById('signalGuessFreqInput');
|
||||
if (!input || !input.value) return;
|
||||
const freq = parseFloat(input.value);
|
||||
if (isNaN(freq) || freq <= 0) return;
|
||||
guessSignal(freq, currentModulation);
|
||||
}
|
||||
|
||||
|
||||
// ============== WATERFALL / SPECTROGRAM ==============
|
||||
|
||||
let isWaterfallRunning = false;
|
||||
let waterfallEventSource = null;
|
||||
let waterfallCanvas = null;
|
||||
let waterfallCtx = null;
|
||||
let spectrumCanvas = null;
|
||||
let spectrumCtx = null;
|
||||
let waterfallStartFreq = 88;
|
||||
let waterfallEndFreq = 108;
|
||||
|
||||
function initWaterfallCanvas() {
|
||||
waterfallCanvas = document.getElementById('waterfallCanvas');
|
||||
spectrumCanvas = document.getElementById('spectrumCanvas');
|
||||
if (waterfallCanvas) waterfallCtx = waterfallCanvas.getContext('2d');
|
||||
if (spectrumCanvas) spectrumCtx = spectrumCanvas.getContext('2d');
|
||||
}
|
||||
|
||||
function dBmToColor(normalized) {
|
||||
// Viridis-inspired: dark blue -> cyan -> green -> yellow
|
||||
const n = Math.max(0, Math.min(1, normalized));
|
||||
let r, g, b;
|
||||
if (n < 0.25) {
|
||||
const t = n / 0.25;
|
||||
r = Math.round(20 + t * 20);
|
||||
g = Math.round(10 + t * 60);
|
||||
b = Math.round(80 + t * 100);
|
||||
} else if (n < 0.5) {
|
||||
const t = (n - 0.25) / 0.25;
|
||||
r = Math.round(40 - t * 20);
|
||||
g = Math.round(70 + t * 130);
|
||||
b = Math.round(180 - t * 30);
|
||||
} else if (n < 0.75) {
|
||||
const t = (n - 0.5) / 0.25;
|
||||
r = Math.round(20 + t * 180);
|
||||
g = Math.round(200 + t * 55);
|
||||
b = Math.round(150 - t * 130);
|
||||
} else {
|
||||
const t = (n - 0.75) / 0.25;
|
||||
r = Math.round(200 + t * 55);
|
||||
g = Math.round(255 - t * 55);
|
||||
b = Math.round(20 - t * 20);
|
||||
}
|
||||
return `rgb(${r},${g},${b})`;
|
||||
}
|
||||
|
||||
function drawWaterfallRow(bins) {
|
||||
if (!waterfallCtx || !waterfallCanvas) return;
|
||||
const w = waterfallCanvas.width;
|
||||
const h = waterfallCanvas.height;
|
||||
|
||||
// Scroll existing content down by 1 pixel
|
||||
const imageData = waterfallCtx.getImageData(0, 0, w, h - 1);
|
||||
waterfallCtx.putImageData(imageData, 0, 1);
|
||||
|
||||
// Find min/max for normalization
|
||||
let minVal = Infinity, maxVal = -Infinity;
|
||||
for (let i = 0; i < bins.length; i++) {
|
||||
if (bins[i] < minVal) minVal = bins[i];
|
||||
if (bins[i] > maxVal) maxVal = bins[i];
|
||||
}
|
||||
const range = maxVal - minVal || 1;
|
||||
|
||||
// Draw new row at top
|
||||
const binWidth = w / bins.length;
|
||||
for (let i = 0; i < bins.length; i++) {
|
||||
const normalized = (bins[i] - minVal) / range;
|
||||
waterfallCtx.fillStyle = dBmToColor(normalized);
|
||||
waterfallCtx.fillRect(Math.floor(i * binWidth), 0, Math.ceil(binWidth) + 1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function drawSpectrumLine(bins, startFreq, endFreq) {
|
||||
if (!spectrumCtx || !spectrumCanvas) return;
|
||||
const w = spectrumCanvas.width;
|
||||
const h = spectrumCanvas.height;
|
||||
|
||||
spectrumCtx.clearRect(0, 0, w, h);
|
||||
|
||||
// Background
|
||||
spectrumCtx.fillStyle = 'rgba(0, 0, 0, 0.8)';
|
||||
spectrumCtx.fillRect(0, 0, w, h);
|
||||
|
||||
// Grid lines
|
||||
spectrumCtx.strokeStyle = 'rgba(0, 200, 255, 0.1)';
|
||||
spectrumCtx.lineWidth = 0.5;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const y = (h / 5) * i;
|
||||
spectrumCtx.beginPath();
|
||||
spectrumCtx.moveTo(0, y);
|
||||
spectrumCtx.lineTo(w, y);
|
||||
spectrumCtx.stroke();
|
||||
}
|
||||
|
||||
// Frequency labels
|
||||
spectrumCtx.fillStyle = 'rgba(0, 200, 255, 0.5)';
|
||||
spectrumCtx.font = '9px monospace';
|
||||
const freqRange = endFreq - startFreq;
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
const freq = startFreq + (freqRange / 4) * i;
|
||||
const x = (w / 4) * i;
|
||||
spectrumCtx.fillText(freq.toFixed(1), x + 2, h - 2);
|
||||
}
|
||||
|
||||
if (bins.length === 0) return;
|
||||
|
||||
// Find min/max for scaling
|
||||
let minVal = Infinity, maxVal = -Infinity;
|
||||
for (let i = 0; i < bins.length; i++) {
|
||||
if (bins[i] < minVal) minVal = bins[i];
|
||||
if (bins[i] > maxVal) maxVal = bins[i];
|
||||
}
|
||||
const range = maxVal - minVal || 1;
|
||||
|
||||
// Draw spectrum line
|
||||
spectrumCtx.strokeStyle = 'rgba(0, 255, 255, 0.9)';
|
||||
spectrumCtx.lineWidth = 1.5;
|
||||
spectrumCtx.beginPath();
|
||||
for (let i = 0; i < bins.length; i++) {
|
||||
const x = (i / (bins.length - 1)) * w;
|
||||
const normalized = (bins[i] - minVal) / range;
|
||||
const y = h - 12 - normalized * (h - 16);
|
||||
if (i === 0) spectrumCtx.moveTo(x, y);
|
||||
else spectrumCtx.lineTo(x, y);
|
||||
}
|
||||
spectrumCtx.stroke();
|
||||
|
||||
// Fill under line
|
||||
const lastX = w;
|
||||
const lastY = h - 12 - ((bins[bins.length - 1] - minVal) / range) * (h - 16);
|
||||
spectrumCtx.lineTo(lastX, h);
|
||||
spectrumCtx.lineTo(0, h);
|
||||
spectrumCtx.closePath();
|
||||
spectrumCtx.fillStyle = 'rgba(0, 255, 255, 0.08)';
|
||||
spectrumCtx.fill();
|
||||
}
|
||||
|
||||
function startWaterfall() {
|
||||
const startFreq = parseFloat(document.getElementById('waterfallStartFreq')?.value || 88);
|
||||
const endFreq = parseFloat(document.getElementById('waterfallEndFreq')?.value || 108);
|
||||
const binSize = parseInt(document.getElementById('waterfallBinSize')?.value || 10000);
|
||||
const gain = parseInt(document.getElementById('waterfallGain')?.value || 40);
|
||||
const device = typeof getSelectedDevice === 'function' ? getSelectedDevice() : 0;
|
||||
|
||||
if (startFreq >= endFreq) {
|
||||
if (typeof showNotification === 'function') showNotification('Error', 'End frequency must be greater than start');
|
||||
return;
|
||||
}
|
||||
|
||||
waterfallStartFreq = startFreq;
|
||||
waterfallEndFreq = endFreq;
|
||||
|
||||
fetch('/listening/waterfall/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ start_freq: startFreq, end_freq: endFreq, bin_size: binSize, gain: gain, device: device })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.status === 'started') {
|
||||
isWaterfallRunning = true;
|
||||
document.getElementById('startWaterfallBtn').style.display = 'none';
|
||||
document.getElementById('stopWaterfallBtn').style.display = 'block';
|
||||
const waterfallPanel = document.getElementById('waterfallPanel');
|
||||
if (waterfallPanel) waterfallPanel.style.display = 'block';
|
||||
initWaterfallCanvas();
|
||||
connectWaterfallSSE();
|
||||
} else {
|
||||
if (typeof showNotification === 'function') showNotification('Error', data.message || 'Failed to start waterfall');
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('[WATERFALL] Start error:', err));
|
||||
}
|
||||
|
||||
function stopWaterfall() {
|
||||
fetch('/listening/waterfall/stop', { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(() => {
|
||||
isWaterfallRunning = false;
|
||||
if (waterfallEventSource) { waterfallEventSource.close(); waterfallEventSource = null; }
|
||||
document.getElementById('startWaterfallBtn').style.display = 'block';
|
||||
document.getElementById('stopWaterfallBtn').style.display = 'none';
|
||||
})
|
||||
.catch(err => console.error('[WATERFALL] Stop error:', err));
|
||||
}
|
||||
|
||||
function connectWaterfallSSE() {
|
||||
if (waterfallEventSource) waterfallEventSource.close();
|
||||
waterfallEventSource = new EventSource('/listening/waterfall/stream');
|
||||
|
||||
waterfallEventSource.onmessage = function(event) {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === 'waterfall_sweep') {
|
||||
drawWaterfallRow(msg.bins);
|
||||
drawSpectrumLine(msg.bins, msg.start_freq, msg.end_freq);
|
||||
}
|
||||
};
|
||||
|
||||
waterfallEventSource.onerror = function() {
|
||||
if (isWaterfallRunning) {
|
||||
setTimeout(connectWaterfallSSE, 2000);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
window.stopDirectListen = stopDirectListen;
|
||||
window.toggleScanner = toggleScanner;
|
||||
window.startScanner = startScanner;
|
||||
@@ -2953,3 +3233,7 @@ window.removeBookmark = removeBookmark;
|
||||
window.tuneToFrequency = tuneToFrequency;
|
||||
window.clearScannerLog = clearScannerLog;
|
||||
window.exportScannerLog = exportScannerLog;
|
||||
window.manualSignalGuess = manualSignalGuess;
|
||||
window.guessSignal = guessSignal;
|
||||
window.startWaterfall = startWaterfall;
|
||||
window.stopWaterfall = stopWaterfall;
|
||||
|
||||
573
static/js/modes/websdr.js
Normal file
573
static/js/modes/websdr.js
Normal file
@@ -0,0 +1,573 @@
|
||||
/**
|
||||
* Intercept - WebSDR Mode
|
||||
* HF/Shortwave KiwiSDR Network Integration with In-App Audio
|
||||
*/
|
||||
|
||||
// ============== STATE ==============
|
||||
let websdrMap = null;
|
||||
let websdrMarkers = [];
|
||||
let websdrReceivers = [];
|
||||
let websdrInitialized = false;
|
||||
let websdrSpyStationsLoaded = false;
|
||||
|
||||
// KiwiSDR audio state
|
||||
let kiwiWebSocket = null;
|
||||
let kiwiAudioContext = null;
|
||||
let kiwiScriptProcessor = null;
|
||||
let kiwiGainNode = null;
|
||||
let kiwiAudioBuffer = [];
|
||||
let kiwiConnected = false;
|
||||
let kiwiCurrentFreq = 0;
|
||||
let kiwiCurrentMode = 'am';
|
||||
let kiwiSmeter = 0;
|
||||
let kiwiSmeterInterval = null;
|
||||
let kiwiReceiverName = '';
|
||||
|
||||
const KIWI_SAMPLE_RATE = 12000;
|
||||
|
||||
// ============== INITIALIZATION ==============
|
||||
|
||||
function initWebSDR() {
|
||||
if (websdrInitialized) {
|
||||
if (websdrMap) {
|
||||
setTimeout(() => websdrMap.invalidateSize(), 100);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const mapEl = document.getElementById('websdrMap');
|
||||
if (!mapEl || typeof L === 'undefined') return;
|
||||
|
||||
websdrMap = L.map('websdrMap', {
|
||||
center: [30, 0],
|
||||
zoom: 2,
|
||||
zoomControl: true,
|
||||
});
|
||||
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||
attribution: '© OpenStreetMap contributors © CARTO',
|
||||
subdomains: 'abcd',
|
||||
maxZoom: 19,
|
||||
}).addTo(websdrMap);
|
||||
|
||||
websdrInitialized = true;
|
||||
|
||||
if (!websdrSpyStationsLoaded) {
|
||||
loadSpyStationPresets();
|
||||
}
|
||||
|
||||
[100, 300, 600, 1000].forEach(delay => {
|
||||
setTimeout(() => {
|
||||
if (websdrMap) websdrMap.invalidateSize();
|
||||
}, delay);
|
||||
});
|
||||
}
|
||||
|
||||
// ============== RECEIVER SEARCH ==============
|
||||
|
||||
function searchReceivers(refresh) {
|
||||
const freqKhz = parseFloat(document.getElementById('websdrFrequency')?.value || 0);
|
||||
|
||||
let url = '/websdr/receivers?available=true';
|
||||
if (freqKhz > 0) url += `&freq_khz=${freqKhz}`;
|
||||
if (refresh) url += '&refresh=true';
|
||||
|
||||
fetch(url)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.status === 'success') {
|
||||
websdrReceivers = data.receivers || [];
|
||||
renderReceiverList(websdrReceivers);
|
||||
plotReceiversOnMap(websdrReceivers);
|
||||
|
||||
const countEl = document.getElementById('websdrReceiverCount');
|
||||
if (countEl) countEl.textContent = `${websdrReceivers.length} found`;
|
||||
const sidebarCount = document.getElementById('websdrSidebarCount');
|
||||
if (sidebarCount) sidebarCount.textContent = websdrReceivers.length;
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('[WEBSDR] Search error:', err));
|
||||
}
|
||||
|
||||
// ============== MAP ==============
|
||||
|
||||
function plotReceiversOnMap(receivers) {
|
||||
if (!websdrMap) return;
|
||||
|
||||
websdrMarkers.forEach(m => websdrMap.removeLayer(m));
|
||||
websdrMarkers = [];
|
||||
|
||||
receivers.forEach((rx, idx) => {
|
||||
if (rx.lat == null || rx.lon == null) return;
|
||||
|
||||
const marker = L.circleMarker([rx.lat, rx.lon], {
|
||||
radius: 6,
|
||||
fillColor: rx.available ? '#00d4ff' : '#666',
|
||||
color: rx.available ? '#00d4ff' : '#666',
|
||||
weight: 1,
|
||||
opacity: 0.8,
|
||||
fillOpacity: 0.6,
|
||||
});
|
||||
|
||||
marker.bindPopup(`
|
||||
<div style="font-size: 12px; min-width: 200px;">
|
||||
<strong>${escapeHtmlWebsdr(rx.name)}</strong><br>
|
||||
${rx.location ? `<span style="color: #aaa;">${escapeHtmlWebsdr(rx.location)}</span><br>` : ''}
|
||||
<span style="color: #888;">Antenna: ${escapeHtmlWebsdr(rx.antenna || 'Unknown')}</span><br>
|
||||
<span style="color: #888;">Users: ${rx.users}/${rx.users_max}</span><br>
|
||||
<button onclick="selectReceiver(${idx})" style="margin-top: 6px; padding: 4px 12px; background: #00d4ff; color: #000; border: none; border-radius: 3px; cursor: pointer; font-weight: bold;">Listen</button>
|
||||
</div>
|
||||
`);
|
||||
|
||||
marker.addTo(websdrMap);
|
||||
websdrMarkers.push(marker);
|
||||
});
|
||||
|
||||
if (websdrMarkers.length > 0) {
|
||||
const group = L.featureGroup(websdrMarkers);
|
||||
websdrMap.fitBounds(group.getBounds(), { padding: [30, 30] });
|
||||
}
|
||||
}
|
||||
|
||||
// ============== RECEIVER LIST ==============
|
||||
|
||||
function renderReceiverList(receivers) {
|
||||
const container = document.getElementById('websdrReceiverList');
|
||||
if (!container) return;
|
||||
|
||||
if (receivers.length === 0) {
|
||||
container.innerHTML = '<div style="color: var(--text-muted); text-align: center; padding: 20px;">No receivers found</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = receivers.slice(0, 50).map((rx, idx) => `
|
||||
<div style="padding: 8px; border-bottom: 1px solid rgba(255,255,255,0.05); cursor: pointer; transition: background 0.2s;"
|
||||
onmouseover="this.style.background='rgba(0,212,255,0.05)'" onmouseout="this.style.background='transparent'"
|
||||
onclick="selectReceiver(${idx})">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<strong style="font-size: 11px; color: var(--text-primary);">${escapeHtmlWebsdr(rx.name)}</strong>
|
||||
<span style="font-size: 9px; padding: 1px 6px; background: ${rx.available ? 'rgba(0,230,118,0.15)' : 'rgba(158,158,158,0.15)'}; color: ${rx.available ? '#00e676' : '#9e9e9e'}; border-radius: 3px;">${rx.users}/${rx.users_max}</span>
|
||||
</div>
|
||||
<div style="font-size: 9px; color: var(--text-muted); margin-top: 2px;">
|
||||
${rx.location ? escapeHtmlWebsdr(rx.location) + ' · ' : ''}${escapeHtmlWebsdr(rx.antenna || '')}
|
||||
${rx.distance_km !== undefined ? ` · ${rx.distance_km} km` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// ============== SELECT RECEIVER ==============
|
||||
|
||||
function selectReceiver(index) {
|
||||
const rx = websdrReceivers[index];
|
||||
if (!rx) return;
|
||||
|
||||
const freqKhz = parseFloat(document.getElementById('websdrFrequency')?.value || 7000);
|
||||
const mode = document.getElementById('websdrMode_select')?.value || 'am';
|
||||
|
||||
kiwiReceiverName = rx.name;
|
||||
|
||||
// Connect via backend proxy
|
||||
connectToReceiver(rx.url, freqKhz, mode);
|
||||
|
||||
// Highlight on map
|
||||
if (websdrMap && rx.lat != null && rx.lon != null) {
|
||||
websdrMap.setView([rx.lat, rx.lon], 6);
|
||||
}
|
||||
}
|
||||
|
||||
// ============== KIWISDR AUDIO CONNECTION ==============
|
||||
|
||||
function connectToReceiver(receiverUrl, freqKhz, mode) {
|
||||
// Disconnect if already connected
|
||||
if (kiwiWebSocket) {
|
||||
disconnectFromReceiver();
|
||||
}
|
||||
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${proto}//${location.host}/ws/kiwi-audio`;
|
||||
|
||||
kiwiWebSocket = new WebSocket(wsUrl);
|
||||
kiwiWebSocket.binaryType = 'arraybuffer';
|
||||
|
||||
kiwiWebSocket.onopen = () => {
|
||||
kiwiWebSocket.send(JSON.stringify({
|
||||
cmd: 'connect',
|
||||
url: receiverUrl,
|
||||
freq_khz: freqKhz,
|
||||
mode: mode,
|
||||
}));
|
||||
updateKiwiUI('connecting');
|
||||
};
|
||||
|
||||
kiwiWebSocket.onmessage = (event) => {
|
||||
if (typeof event.data === 'string') {
|
||||
const msg = JSON.parse(event.data);
|
||||
handleKiwiStatus(msg);
|
||||
} else {
|
||||
handleKiwiAudio(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
kiwiWebSocket.onclose = () => {
|
||||
kiwiConnected = false;
|
||||
updateKiwiUI('disconnected');
|
||||
};
|
||||
|
||||
kiwiWebSocket.onerror = () => {
|
||||
updateKiwiUI('disconnected');
|
||||
};
|
||||
}
|
||||
|
||||
function handleKiwiStatus(msg) {
|
||||
switch (msg.type) {
|
||||
case 'connected':
|
||||
kiwiConnected = true;
|
||||
kiwiCurrentFreq = msg.freq_khz;
|
||||
kiwiCurrentMode = msg.mode;
|
||||
initKiwiAudioContext(msg.sample_rate || KIWI_SAMPLE_RATE);
|
||||
updateKiwiUI('connected');
|
||||
break;
|
||||
case 'tuned':
|
||||
kiwiCurrentFreq = msg.freq_khz;
|
||||
kiwiCurrentMode = msg.mode;
|
||||
updateKiwiUI('connected');
|
||||
break;
|
||||
case 'error':
|
||||
console.error('[KIWI] Error:', msg.message);
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification('WebSDR', msg.message);
|
||||
}
|
||||
updateKiwiUI('error');
|
||||
break;
|
||||
case 'disconnected':
|
||||
kiwiConnected = false;
|
||||
cleanupKiwiAudio();
|
||||
updateKiwiUI('disconnected');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function handleKiwiAudio(arrayBuffer) {
|
||||
if (arrayBuffer.byteLength < 4) return;
|
||||
|
||||
// First 2 bytes: S-meter (big-endian int16)
|
||||
const view = new DataView(arrayBuffer);
|
||||
kiwiSmeter = view.getInt16(0, false);
|
||||
|
||||
// Remaining bytes: PCM 16-bit signed LE
|
||||
const pcmData = new Int16Array(arrayBuffer, 2);
|
||||
|
||||
// Convert to float32 [-1, 1] for Web Audio API
|
||||
const float32 = new Float32Array(pcmData.length);
|
||||
for (let i = 0; i < pcmData.length; i++) {
|
||||
float32[i] = pcmData[i] / 32768.0;
|
||||
}
|
||||
|
||||
// Add to playback buffer (limit buffer size to ~2s)
|
||||
kiwiAudioBuffer.push(float32);
|
||||
const maxChunks = Math.ceil((KIWI_SAMPLE_RATE * 2) / 512);
|
||||
while (kiwiAudioBuffer.length > maxChunks) {
|
||||
kiwiAudioBuffer.shift();
|
||||
}
|
||||
}
|
||||
|
||||
function initKiwiAudioContext(sampleRate) {
|
||||
cleanupKiwiAudio();
|
||||
|
||||
kiwiAudioContext = new (window.AudioContext || window.webkitAudioContext)({
|
||||
sampleRate: sampleRate,
|
||||
});
|
||||
|
||||
// Resume if suspended (autoplay policy)
|
||||
if (kiwiAudioContext.state === 'suspended') {
|
||||
kiwiAudioContext.resume();
|
||||
}
|
||||
|
||||
// ScriptProcessorNode: pulls audio from buffer
|
||||
kiwiScriptProcessor = kiwiAudioContext.createScriptProcessor(2048, 0, 1);
|
||||
kiwiScriptProcessor.onaudioprocess = (e) => {
|
||||
const output = e.outputBuffer.getChannelData(0);
|
||||
let offset = 0;
|
||||
|
||||
while (offset < output.length && kiwiAudioBuffer.length > 0) {
|
||||
const chunk = kiwiAudioBuffer[0];
|
||||
const needed = output.length - offset;
|
||||
const available = chunk.length;
|
||||
|
||||
if (available <= needed) {
|
||||
output.set(chunk, offset);
|
||||
offset += available;
|
||||
kiwiAudioBuffer.shift();
|
||||
} else {
|
||||
output.set(chunk.subarray(0, needed), offset);
|
||||
kiwiAudioBuffer[0] = chunk.subarray(needed);
|
||||
offset += needed;
|
||||
}
|
||||
}
|
||||
|
||||
// Fill remaining with silence
|
||||
while (offset < output.length) {
|
||||
output[offset++] = 0;
|
||||
}
|
||||
};
|
||||
|
||||
// Volume control
|
||||
kiwiGainNode = kiwiAudioContext.createGain();
|
||||
const savedVol = localStorage.getItem('kiwiVolume');
|
||||
kiwiGainNode.gain.value = savedVol !== null ? parseFloat(savedVol) / 100 : 0.8;
|
||||
const volValue = Math.round(kiwiGainNode.gain.value * 100);
|
||||
['kiwiVolume', 'kiwiBarVolume'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.value = volValue;
|
||||
});
|
||||
|
||||
kiwiScriptProcessor.connect(kiwiGainNode);
|
||||
kiwiGainNode.connect(kiwiAudioContext.destination);
|
||||
|
||||
// S-meter display updates
|
||||
if (kiwiSmeterInterval) clearInterval(kiwiSmeterInterval);
|
||||
kiwiSmeterInterval = setInterval(updateSmeterDisplay, 200);
|
||||
}
|
||||
|
||||
function disconnectFromReceiver() {
|
||||
if (kiwiWebSocket && kiwiWebSocket.readyState === WebSocket.OPEN) {
|
||||
kiwiWebSocket.send(JSON.stringify({ cmd: 'disconnect' }));
|
||||
}
|
||||
cleanupKiwiAudio();
|
||||
if (kiwiWebSocket) {
|
||||
kiwiWebSocket.close();
|
||||
kiwiWebSocket = null;
|
||||
}
|
||||
kiwiConnected = false;
|
||||
kiwiReceiverName = '';
|
||||
updateKiwiUI('disconnected');
|
||||
}
|
||||
|
||||
function cleanupKiwiAudio() {
|
||||
if (kiwiSmeterInterval) {
|
||||
clearInterval(kiwiSmeterInterval);
|
||||
kiwiSmeterInterval = null;
|
||||
}
|
||||
if (kiwiScriptProcessor) {
|
||||
kiwiScriptProcessor.disconnect();
|
||||
kiwiScriptProcessor = null;
|
||||
}
|
||||
if (kiwiGainNode) {
|
||||
kiwiGainNode.disconnect();
|
||||
kiwiGainNode = null;
|
||||
}
|
||||
if (kiwiAudioContext) {
|
||||
kiwiAudioContext.close().catch(() => {});
|
||||
kiwiAudioContext = null;
|
||||
}
|
||||
kiwiAudioBuffer = [];
|
||||
kiwiSmeter = 0;
|
||||
}
|
||||
|
||||
function tuneKiwi(freqKhz, mode) {
|
||||
if (!kiwiWebSocket || !kiwiConnected) return;
|
||||
kiwiWebSocket.send(JSON.stringify({
|
||||
cmd: 'tune',
|
||||
freq_khz: freqKhz,
|
||||
mode: mode || kiwiCurrentMode,
|
||||
}));
|
||||
}
|
||||
|
||||
function tuneFromBar() {
|
||||
const freq = parseFloat(document.getElementById('kiwiBarFrequency')?.value || 0);
|
||||
const mode = document.getElementById('kiwiBarMode')?.value || kiwiCurrentMode;
|
||||
if (freq > 0) {
|
||||
tuneKiwi(freq, mode);
|
||||
// Also update sidebar frequency
|
||||
const freqInput = document.getElementById('websdrFrequency');
|
||||
if (freqInput) freqInput.value = freq;
|
||||
}
|
||||
}
|
||||
|
||||
function setKiwiVolume(value) {
|
||||
if (kiwiGainNode) {
|
||||
kiwiGainNode.gain.value = value / 100;
|
||||
localStorage.setItem('kiwiVolume', value);
|
||||
}
|
||||
// Sync both volume sliders
|
||||
['kiwiVolume', 'kiwiBarVolume'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el && el.value !== String(value)) el.value = value;
|
||||
});
|
||||
}
|
||||
|
||||
// ============== S-METER ==============
|
||||
|
||||
function updateSmeterDisplay() {
|
||||
// KiwiSDR S-meter: value in 0.1 dBm units (e.g., -730 = -73 dBm = S9)
|
||||
const dbm = kiwiSmeter / 10;
|
||||
let sUnit;
|
||||
if (dbm >= -73) {
|
||||
const over = Math.round((dbm + 73));
|
||||
sUnit = over > 0 ? `S9+${over}` : 'S9';
|
||||
} else {
|
||||
sUnit = `S${Math.max(0, Math.round((dbm + 127) / 6))}`;
|
||||
}
|
||||
|
||||
const pct = Math.min(100, Math.max(0, (dbm + 127) / 1.27));
|
||||
|
||||
// Update both sidebar and bar S-meter displays
|
||||
['kiwiSmeterBar', 'kiwiBarSmeter'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.style.width = pct + '%';
|
||||
});
|
||||
['kiwiSmeterValue', 'kiwiBarSmeterValue'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = sUnit;
|
||||
});
|
||||
}
|
||||
|
||||
// ============== UI UPDATES ==============
|
||||
|
||||
function updateKiwiUI(state) {
|
||||
const statusEl = document.getElementById('kiwiStatus');
|
||||
const controlsBar = document.getElementById('kiwiAudioControls');
|
||||
const disconnectBtn = document.getElementById('kiwiDisconnectBtn');
|
||||
const receiverNameEl = document.getElementById('kiwiReceiverName');
|
||||
const freqDisplay = document.getElementById('kiwiFreqDisplay');
|
||||
const barReceiverName = document.getElementById('kiwiBarReceiverName');
|
||||
const barFreq = document.getElementById('kiwiBarFrequency');
|
||||
const barMode = document.getElementById('kiwiBarMode');
|
||||
|
||||
if (state === 'connected') {
|
||||
if (statusEl) {
|
||||
statusEl.textContent = 'CONNECTED';
|
||||
statusEl.style.color = 'var(--accent-green)';
|
||||
}
|
||||
if (controlsBar) controlsBar.style.display = 'block';
|
||||
if (disconnectBtn) disconnectBtn.style.display = 'block';
|
||||
if (receiverNameEl) {
|
||||
receiverNameEl.textContent = kiwiReceiverName;
|
||||
receiverNameEl.style.display = 'block';
|
||||
}
|
||||
if (freqDisplay) freqDisplay.textContent = kiwiCurrentFreq + ' kHz';
|
||||
if (barReceiverName) barReceiverName.textContent = kiwiReceiverName;
|
||||
if (barFreq) barFreq.value = kiwiCurrentFreq;
|
||||
if (barMode) barMode.value = kiwiCurrentMode;
|
||||
} else if (state === 'connecting') {
|
||||
if (statusEl) {
|
||||
statusEl.textContent = 'CONNECTING...';
|
||||
statusEl.style.color = 'var(--accent-orange)';
|
||||
}
|
||||
} else if (state === 'error') {
|
||||
if (statusEl) {
|
||||
statusEl.textContent = 'ERROR';
|
||||
statusEl.style.color = 'var(--accent-red)';
|
||||
}
|
||||
} else {
|
||||
// disconnected
|
||||
if (statusEl) {
|
||||
statusEl.textContent = 'DISCONNECTED';
|
||||
statusEl.style.color = 'var(--text-muted)';
|
||||
}
|
||||
if (controlsBar) controlsBar.style.display = 'none';
|
||||
if (disconnectBtn) disconnectBtn.style.display = 'none';
|
||||
if (receiverNameEl) receiverNameEl.style.display = 'none';
|
||||
if (freqDisplay) freqDisplay.textContent = '--- kHz';
|
||||
// Reset both S-meter displays (sidebar + bar)
|
||||
['kiwiSmeterBar', 'kiwiBarSmeter'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.style.width = '0%';
|
||||
});
|
||||
['kiwiSmeterValue', 'kiwiBarSmeterValue'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = 'S0';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ============== SPY STATION PRESETS ==============
|
||||
|
||||
function loadSpyStationPresets() {
|
||||
fetch('/spy-stations/stations')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
websdrSpyStationsLoaded = true;
|
||||
const container = document.getElementById('websdrSpyPresets');
|
||||
if (!container) return;
|
||||
|
||||
const stations = data.stations || data || [];
|
||||
if (!Array.isArray(stations) || stations.length === 0) {
|
||||
container.innerHTML = '<div style="color: var(--text-muted); text-align: center; padding: 10px;">No stations available</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = stations.slice(0, 30).map(s => {
|
||||
const primaryFreq = s.frequencies?.find(f => f.primary) || s.frequencies?.[0];
|
||||
const freqKhz = primaryFreq?.freq_khz || 0;
|
||||
return `
|
||||
<div style="padding: 6px 4px; border-bottom: 1px solid rgba(255,255,255,0.05); cursor: pointer; display: flex; justify-content: space-between; align-items: center;"
|
||||
onclick="tuneToSpyStation('${escapeHtmlWebsdr(s.id)}', ${freqKhz})"
|
||||
onmouseover="this.style.background='rgba(0,212,255,0.05)'" onmouseout="this.style.background='transparent'">
|
||||
<div>
|
||||
<span style="color: var(--accent-cyan); font-weight: bold;">${escapeHtmlWebsdr(s.name)}</span>
|
||||
<span style="color: var(--text-muted); font-size: 9px; margin-left: 4px;">${escapeHtmlWebsdr(s.nickname || '')}</span>
|
||||
</div>
|
||||
<span style="color: var(--accent-orange); font-family: var(--font-mono); font-size: 10px;">${freqKhz} kHz</span>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('[WEBSDR] Failed to load spy station presets:', err);
|
||||
});
|
||||
}
|
||||
|
||||
function tuneToSpyStation(stationId, freqKhz) {
|
||||
const freqInput = document.getElementById('websdrFrequency');
|
||||
if (freqInput) freqInput.value = freqKhz;
|
||||
|
||||
// If already connected, just retune
|
||||
if (kiwiConnected) {
|
||||
const mode = document.getElementById('websdrMode_select')?.value || kiwiCurrentMode;
|
||||
tuneKiwi(freqKhz, mode);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, search for receivers at this frequency
|
||||
fetch(`/websdr/spy-station/${encodeURIComponent(stationId)}/receivers`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.status === 'success') {
|
||||
websdrReceivers = data.receivers || [];
|
||||
renderReceiverList(websdrReceivers);
|
||||
plotReceiversOnMap(websdrReceivers);
|
||||
|
||||
const countEl = document.getElementById('websdrReceiverCount');
|
||||
if (countEl) countEl.textContent = `${websdrReceivers.length} for ${data.station?.name || stationId}`;
|
||||
|
||||
if (typeof showNotification === 'function' && data.station) {
|
||||
showNotification('WebSDR', `Found ${websdrReceivers.length} receivers for ${data.station.name} at ${freqKhz} kHz`);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('[WEBSDR] Spy station receivers error:', err));
|
||||
}
|
||||
|
||||
// ============== UTILITIES ==============
|
||||
|
||||
function escapeHtmlWebsdr(str) {
|
||||
if (!str) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// ============== EXPORTS ==============
|
||||
|
||||
window.initWebSDR = initWebSDR;
|
||||
window.searchReceivers = searchReceivers;
|
||||
window.selectReceiver = selectReceiver;
|
||||
window.tuneToSpyStation = tuneToSpyStation;
|
||||
window.loadSpyStationPresets = loadSpyStationPresets;
|
||||
window.connectToReceiver = connectToReceiver;
|
||||
window.disconnectFromReceiver = disconnectFromReceiver;
|
||||
window.tuneKiwi = tuneKiwi;
|
||||
window.tuneFromBar = tuneFromBar;
|
||||
window.setKiwiVolume = setKiwiVolume;
|
||||
Reference in New Issue
Block a user