[feat](trx-frontend): add spectrum sweet-spot scan
Co-authored-by: OpenAI Codex <codex@openai.com> Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
@@ -1167,13 +1167,13 @@ function effectiveSpectrumCoverageSpanHz(sampleRateHz) {
|
|||||||
const sampleRate = Number(sampleRateHz);
|
const sampleRate = Number(sampleRateHz);
|
||||||
if (!Number.isFinite(sampleRate) || sampleRate <= 0) return 0;
|
if (!Number.isFinite(sampleRate) || sampleRate <= 0) return 0;
|
||||||
// Keep a guard band at the spectrum edges; practical usable span is slightly smaller.
|
// Keep a guard band at the spectrum edges; practical usable span is slightly smaller.
|
||||||
return sampleRate * 0.82;
|
return sampleRate * 0.92;
|
||||||
}
|
}
|
||||||
|
|
||||||
function requiredCenterFreqForCoverage(freqHz, bandwidthHz = coverageGuardBandwidthHz()) {
|
function requiredCenterFreqForCoverageInFrame(data, freqHz, bandwidthHz = coverageGuardBandwidthHz()) {
|
||||||
if (!lastSpectrumData || !Number.isFinite(freqHz)) return null;
|
if (!data || !Number.isFinite(freqHz)) return null;
|
||||||
const sampleRate = effectiveSpectrumCoverageSpanHz(lastSpectrumData.sample_rate);
|
const sampleRate = effectiveSpectrumCoverageSpanHz(data.sample_rate);
|
||||||
const currentCenterHz = Number(lastSpectrumData.center_hz);
|
const currentCenterHz = Number(data.center_hz);
|
||||||
if (!Number.isFinite(sampleRate) || sampleRate <= 0 || !Number.isFinite(currentCenterHz)) {
|
if (!Number.isFinite(sampleRate) || sampleRate <= 0 || !Number.isFinite(currentCenterHz)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -1203,6 +1203,10 @@ function requiredCenterFreqForCoverage(freqHz, bandwidthHz = coverageGuardBandwi
|
|||||||
return alignFreqToRigStep(Math.round(nextCenterHz));
|
return alignFreqToRigStep(Math.round(nextCenterHz));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requiredCenterFreqForCoverage(freqHz, bandwidthHz = coverageGuardBandwidthHz()) {
|
||||||
|
return requiredCenterFreqForCoverageInFrame(lastSpectrumData, freqHz, bandwidthHz);
|
||||||
|
}
|
||||||
|
|
||||||
async function ensureTunedBandwidthCoverage(freqHz, bandwidthHz = coverageGuardBandwidthHz()) {
|
async function ensureTunedBandwidthCoverage(freqHz, bandwidthHz = coverageGuardBandwidthHz()) {
|
||||||
const nextCenterHz = requiredCenterFreqForCoverage(freqHz, bandwidthHz);
|
const nextCenterHz = requiredCenterFreqForCoverage(freqHz, bandwidthHz);
|
||||||
if (!Number.isFinite(nextCenterHz)) return;
|
if (!Number.isFinite(nextCenterHz)) return;
|
||||||
@@ -1220,6 +1224,221 @@ async function setRigFrequency(freqHz) {
|
|||||||
await ensureTunedBandwidthCoverage(targetHz);
|
await ensureTunedBandwidthCoverage(targetHz);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function spectrumBinIndexForHz(data, hz) {
|
||||||
|
if (!data || !Array.isArray(data.bins) || data.bins.length < 2 || !Number.isFinite(hz)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const maxIdx = data.bins.length - 1;
|
||||||
|
const fullLoHz = Number(data.center_hz) - Number(data.sample_rate) / 2;
|
||||||
|
const idx = Math.round(((hz - fullLoHz) / Number(data.sample_rate)) * maxIdx);
|
||||||
|
return Math.max(0, Math.min(maxIdx, idx));
|
||||||
|
}
|
||||||
|
|
||||||
|
function spectrumPowerScore(db) {
|
||||||
|
const value = Number.isFinite(db) ? db : -160;
|
||||||
|
const clamped = Math.max(-160, Math.min(40, value));
|
||||||
|
return 10 ** (clamped / 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sweetSpotCandidateForFrame(data, freqHz, bandwidthHz) {
|
||||||
|
if (!data || !Array.isArray(data.bins) || data.bins.length < 16) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(freqHz) || !Number.isFinite(bandwidthHz) || bandwidthHz <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bins = data.bins;
|
||||||
|
const sampleRate = Number(data.sample_rate);
|
||||||
|
const usableSpanHz = effectiveSpectrumCoverageSpanHz(sampleRate);
|
||||||
|
const currentCenterHz = Number(data.center_hz);
|
||||||
|
if (!Number.isFinite(sampleRate) || sampleRate <= 0 || !Number.isFinite(usableSpanHz) || usableSpanHz <= 0 || !Number.isFinite(currentCenterHz)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const halfUsableSpanHz = usableSpanHz / 2;
|
||||||
|
const fullHalfSpanHz = sampleRate / 2;
|
||||||
|
const guardHalfSpanHz = bandwidthHz / 2 + SPECTRUM_COVERAGE_MARGIN_HZ;
|
||||||
|
if (guardHalfSpanHz * 2 >= usableSpanHz) {
|
||||||
|
const fallbackCenterHz = requiredCenterFreqForCoverageInFrame(data, freqHz, bandwidthHz);
|
||||||
|
if (!Number.isFinite(fallbackCenterHz)) return null;
|
||||||
|
return { centerHz: fallbackCenterHz, score: Number.POSITIVE_INFINITY };
|
||||||
|
}
|
||||||
|
|
||||||
|
const evalHalfSpanHz = Math.max(0, (sampleRate - usableSpanHz) / 2);
|
||||||
|
const evalMinCenterHz = currentCenterHz - evalHalfSpanHz;
|
||||||
|
const evalMaxCenterHz = currentCenterHz + evalHalfSpanHz;
|
||||||
|
const fitMinCenterHz = freqHz + guardHalfSpanHz - halfUsableSpanHz;
|
||||||
|
const fitMaxCenterHz = freqHz - guardHalfSpanHz + halfUsableSpanHz;
|
||||||
|
const minCenterHz = Math.max(evalMinCenterHz, fitMinCenterHz);
|
||||||
|
const maxCenterHz = Math.min(evalMaxCenterHz, fitMaxCenterHz);
|
||||||
|
if (!Number.isFinite(minCenterHz) || !Number.isFinite(maxCenterHz) || minCenterHz > maxCenterHz) {
|
||||||
|
const fallbackCenterHz = requiredCenterFreqForCoverageInFrame(data, freqHz, bandwidthHz);
|
||||||
|
if (!Number.isFinite(fallbackCenterHz)) return null;
|
||||||
|
return { centerHz: fallbackCenterHz, score: Number.POSITIVE_INFINITY };
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxIdx = bins.length - 1;
|
||||||
|
const usableBins = Math.max(4, Math.min(maxIdx, Math.round((usableSpanHz / sampleRate) * maxIdx)));
|
||||||
|
const fullLoHz = currentCenterHz - fullHalfSpanHz;
|
||||||
|
const startMinIdx = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(maxIdx - usableBins, Math.round((((minCenterHz - halfUsableSpanHz) - fullLoHz) / sampleRate) * maxIdx)),
|
||||||
|
);
|
||||||
|
const startMaxIdx = Math.max(
|
||||||
|
startMinIdx,
|
||||||
|
Math.min(maxIdx - usableBins, Math.round((((maxCenterHz - halfUsableSpanHz) - fullLoHz) / sampleRate) * maxIdx)),
|
||||||
|
);
|
||||||
|
|
||||||
|
let bestStartIdx = null;
|
||||||
|
let bestScore = Number.POSITIVE_INFINITY;
|
||||||
|
const signalLoHz = freqHz - bandwidthHz / 2;
|
||||||
|
const signalHiHz = freqHz + bandwidthHz / 2;
|
||||||
|
|
||||||
|
for (let startIdx = startMinIdx; startIdx <= startMaxIdx; startIdx += 1) {
|
||||||
|
const endIdx = Math.min(maxIdx, startIdx + usableBins);
|
||||||
|
const windowLoHz = fullLoHz + (startIdx / maxIdx) * sampleRate;
|
||||||
|
const candidateCenterHz = windowLoHz + halfUsableSpanHz;
|
||||||
|
const signalLoIdx = Math.max(startIdx, Math.min(endIdx, spectrumBinIndexForHz(data, signalLoHz)));
|
||||||
|
const signalHiIdx = Math.max(startIdx, Math.min(endIdx, spectrumBinIndexForHz(data, signalHiHz)));
|
||||||
|
|
||||||
|
let score = 0;
|
||||||
|
for (let i = startIdx; i <= endIdx; i++) {
|
||||||
|
if (i >= signalLoIdx && i <= signalHiIdx) continue;
|
||||||
|
score += spectrumPowerScore(bins[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep a very small bias toward a reasonably centered passband when scores are close.
|
||||||
|
const centeredOffsetHz = Math.abs(candidateCenterHz - freqHz);
|
||||||
|
score *= 1 + centeredOffsetHz / Math.max(usableSpanHz, 1) * 0.08;
|
||||||
|
if (score < bestScore) {
|
||||||
|
bestScore = score;
|
||||||
|
bestStartIdx = startIdx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isFinite(bestScore) || bestStartIdx == null) {
|
||||||
|
const fallbackCenterHz = requiredCenterFreqForCoverageInFrame(data, freqHz, bandwidthHz);
|
||||||
|
if (!Number.isFinite(fallbackCenterHz)) return null;
|
||||||
|
return { centerHz: fallbackCenterHz, score: Number.POSITIVE_INFINITY };
|
||||||
|
}
|
||||||
|
|
||||||
|
const bestLoHz = fullLoHz + (bestStartIdx / maxIdx) * sampleRate;
|
||||||
|
const bestCenterHz = bestLoHz + halfUsableSpanHz;
|
||||||
|
return {
|
||||||
|
centerHz: alignFreqToRigStep(Math.round(bestCenterHz)),
|
||||||
|
score: bestScore,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sweetSpotCenterFreq(freqHz = lastFreqHz, bandwidthHz = currentBandwidthHz) {
|
||||||
|
const candidate = sweetSpotCandidateForFrame(lastSpectrumData, freqHz, bandwidthHz);
|
||||||
|
return candidate && Number.isFinite(candidate.centerHz) ? candidate.centerHz : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sweetSpotProbeCenters(data, freqHz, bandwidthHz) {
|
||||||
|
if (!data || !Number.isFinite(freqHz) || !Number.isFinite(bandwidthHz) || bandwidthHz <= 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const sampleRate = Number(data.sample_rate);
|
||||||
|
const usableSpanHz = effectiveSpectrumCoverageSpanHz(sampleRate);
|
||||||
|
if (!Number.isFinite(usableSpanHz) || usableSpanHz <= 0) return [];
|
||||||
|
|
||||||
|
const halfUsableSpanHz = usableSpanHz / 2;
|
||||||
|
const guardHalfSpanHz = bandwidthHz / 2 + SPECTRUM_COVERAGE_MARGIN_HZ;
|
||||||
|
if (guardHalfSpanHz * 2 >= usableSpanHz) {
|
||||||
|
return [alignFreqToRigStep(Math.round(freqHz))];
|
||||||
|
}
|
||||||
|
|
||||||
|
const minCenterHz = freqHz + guardHalfSpanHz - halfUsableSpanHz;
|
||||||
|
const maxCenterHz = freqHz - guardHalfSpanHz + halfUsableSpanHz;
|
||||||
|
if (!Number.isFinite(minCenterHz) || !Number.isFinite(maxCenterHz) || minCenterHz > maxCenterHz) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const points = 5;
|
||||||
|
const centers = [];
|
||||||
|
for (let i = 0; i < points; i++) {
|
||||||
|
const frac = points === 1 ? 0.5 : i / (points - 1);
|
||||||
|
const centerHz = alignFreqToRigStep(Math.round(minCenterHz + (maxCenterHz - minCenterHz) * frac));
|
||||||
|
if (!centers.some((value) => Math.abs(value - centerHz) < 1)) {
|
||||||
|
centers.push(centerHz);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentCenterHz = alignFreqToRigStep(Math.round(Number(data.center_hz)));
|
||||||
|
if (Number.isFinite(currentCenterHz) && !centers.some((value) => Math.abs(value - currentCenterHz) < 1)) {
|
||||||
|
centers.push(currentCenterHz);
|
||||||
|
centers.sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
return centers;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applySweetSpotCenter() {
|
||||||
|
if (sweetSpotScanInFlight) {
|
||||||
|
showHint("Sweet-spot already scanning", 900);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(lastFreqHz) || !lastSpectrumData) return;
|
||||||
|
|
||||||
|
const originalCenterHz = Number(lastSpectrumData.center_hz);
|
||||||
|
const probeCentersHz = sweetSpotProbeCenters(lastSpectrumData, lastFreqHz, currentBandwidthHz);
|
||||||
|
let bestCandidate = sweetSpotCandidateForFrame(lastSpectrumData, lastFreqHz, currentBandwidthHz);
|
||||||
|
if (!probeCentersHz.length && (!bestCandidate || !Number.isFinite(bestCandidate.centerHz))) {
|
||||||
|
showHint("Sweet-spot unavailable", 1100);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sweetSpotScanInFlight = true;
|
||||||
|
try {
|
||||||
|
showHint("Scanning sweet spot...", 1400);
|
||||||
|
|
||||||
|
for (const probeCenterHz of probeCentersHz) {
|
||||||
|
if (!Number.isFinite(probeCenterHz)) continue;
|
||||||
|
let probeFrame = lastSpectrumData;
|
||||||
|
if (!probeFrame || Math.abs(Number(probeFrame.center_hz) - probeCenterHz) >= 1) {
|
||||||
|
await postPath(`/set_center_freq?hz=${probeCenterHz}`);
|
||||||
|
try {
|
||||||
|
probeFrame = await waitForSpectrumFrame(probeCenterHz, 1400);
|
||||||
|
} catch (_) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = sweetSpotCandidateForFrame(probeFrame, lastFreqHz, currentBandwidthHz);
|
||||||
|
if (!candidate || !Number.isFinite(candidate.centerHz)) continue;
|
||||||
|
if (!bestCandidate || candidate.score < bestCandidate.score) {
|
||||||
|
bestCandidate = candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetCenterHz = bestCandidate && Number.isFinite(bestCandidate.centerHz)
|
||||||
|
? bestCandidate.centerHz
|
||||||
|
: sweetSpotCenterFreq(lastFreqHz, currentBandwidthHz);
|
||||||
|
if (!Number.isFinite(targetCenterHz)) {
|
||||||
|
if (Number.isFinite(originalCenterHz) && (!lastSpectrumData || Math.abs(Number(lastSpectrumData.center_hz) - originalCenterHz) >= 1)) {
|
||||||
|
await postPath(`/set_center_freq?hz=${alignFreqToRigStep(Math.round(originalCenterHz))}`);
|
||||||
|
}
|
||||||
|
showHint("Sweet-spot unavailable", 1100);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!lastSpectrumData || Math.abs(targetCenterHz - Number(lastSpectrumData.center_hz)) >= 1) {
|
||||||
|
await postPath(`/set_center_freq?hz=${targetCenterHz}`);
|
||||||
|
}
|
||||||
|
if (centerFreqEl && !centerFreqDirty) {
|
||||||
|
centerFreqEl.value = formatFreqForStep(targetCenterHz, jogUnit);
|
||||||
|
}
|
||||||
|
if (Number.isFinite(originalCenterHz) && Math.abs(targetCenterHz - originalCenterHz) < 1) {
|
||||||
|
showHint("Already at sweet spot", 900);
|
||||||
|
} else {
|
||||||
|
showHint("Sweet-spot set", 1200);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
sweetSpotScanInFlight = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function tunedFrequencyForCenterCoverage(centerHz, freqHz = lastFreqHz, bandwidthHz = coverageGuardBandwidthHz()) {
|
function tunedFrequencyForCenterCoverage(centerHz, freqHz = lastFreqHz, bandwidthHz = coverageGuardBandwidthHz()) {
|
||||||
if (!Number.isFinite(centerHz) || !Number.isFinite(freqHz) || !lastSpectrumData) return null;
|
if (!Number.isFinite(centerHz) || !Number.isFinite(freqHz) || !lastSpectrumData) return null;
|
||||||
const sampleRate = effectiveSpectrumCoverageSpanHz(lastSpectrumData.sample_rate);
|
const sampleRate = effectiveSpectrumCoverageSpanHz(lastSpectrumData.sample_rate);
|
||||||
@@ -2337,6 +2556,7 @@ let currentBandwidthHz = 3_000;
|
|||||||
const spectrumBwInput = document.getElementById("spectrum-bw-input");
|
const spectrumBwInput = document.getElementById("spectrum-bw-input");
|
||||||
const spectrumBwSetBtn = document.getElementById("spectrum-bw-set-btn");
|
const spectrumBwSetBtn = document.getElementById("spectrum-bw-set-btn");
|
||||||
const spectrumBwAutoBtn = document.getElementById("spectrum-bw-auto-btn");
|
const spectrumBwAutoBtn = document.getElementById("spectrum-bw-auto-btn");
|
||||||
|
const spectrumBwSweetBtn = document.getElementById("spectrum-bw-sweet-btn");
|
||||||
|
|
||||||
function formatBandwidthInputKhz(hz) {
|
function formatBandwidthInputKhz(hz) {
|
||||||
const khz = hz / 1000;
|
const khz = hz / 1000;
|
||||||
@@ -2472,6 +2692,9 @@ if (spectrumBwSetBtn) {
|
|||||||
if (spectrumBwAutoBtn) {
|
if (spectrumBwAutoBtn) {
|
||||||
spectrumBwAutoBtn.addEventListener("click", () => { applyAutoBandwidth(); });
|
spectrumBwAutoBtn.addEventListener("click", () => { applyAutoBandwidth(); });
|
||||||
}
|
}
|
||||||
|
if (spectrumBwSweetBtn) {
|
||||||
|
spectrumBwSweetBtn.addEventListener("click", () => { applySweetSpotCenter().catch(() => {}); });
|
||||||
|
}
|
||||||
|
|
||||||
// --- Tab navigation ---
|
// --- Tab navigation ---
|
||||||
document.querySelector(".tab-bar").addEventListener("click", (e) => {
|
document.querySelector(".tab-bar").addEventListener("click", (e) => {
|
||||||
@@ -3731,6 +3954,8 @@ let spectrumDrawPending = false;
|
|||||||
let spectrumAxisKey = "";
|
let spectrumAxisKey = "";
|
||||||
let lastSpectrumRenderData = null;
|
let lastSpectrumRenderData = null;
|
||||||
let spectrumPeakHoldFrames = [];
|
let spectrumPeakHoldFrames = [];
|
||||||
|
let pendingSpectrumFrameWaiters = [];
|
||||||
|
let sweetSpotScanInFlight = false;
|
||||||
|
|
||||||
// Zoom / pan state. zoom >= 1; panFrac in [0,1] is the fraction of the full
|
// Zoom / pan state. zoom >= 1; panFrac in [0,1] is the fraction of the full
|
||||||
// bandwidth at the centre of the visible window.
|
// bandwidth at the centre of the visible window.
|
||||||
@@ -3757,6 +3982,69 @@ function clearSpectrumPeakHoldFrames() {
|
|||||||
spectrumPeakHoldFrames = [];
|
spectrumPeakHoldFrames = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function settlePendingSpectrumFrameWaiters(frame) {
|
||||||
|
if (!pendingSpectrumFrameWaiters.length) return;
|
||||||
|
const remaining = [];
|
||||||
|
for (const waiter of pendingSpectrumFrameWaiters) {
|
||||||
|
if (!waiter) continue;
|
||||||
|
const targetCenterHz = Number(waiter.targetCenterHz);
|
||||||
|
if (
|
||||||
|
Number.isFinite(targetCenterHz) &&
|
||||||
|
(!frame || Math.abs(Number(frame.center_hz) - targetCenterHz) >= 2)
|
||||||
|
) {
|
||||||
|
remaining.push(waiter);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (waiter.timer) {
|
||||||
|
clearTimeout(waiter.timer);
|
||||||
|
waiter.timer = null;
|
||||||
|
}
|
||||||
|
if (typeof waiter.resolve === "function") {
|
||||||
|
waiter.resolve(frame);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pendingSpectrumFrameWaiters = remaining;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rejectPendingSpectrumFrameWaiters(error) {
|
||||||
|
if (!pendingSpectrumFrameWaiters.length) return;
|
||||||
|
for (const waiter of pendingSpectrumFrameWaiters) {
|
||||||
|
if (!waiter) continue;
|
||||||
|
if (waiter.timer) {
|
||||||
|
clearTimeout(waiter.timer);
|
||||||
|
waiter.timer = null;
|
||||||
|
}
|
||||||
|
if (typeof waiter.reject === "function") {
|
||||||
|
waiter.reject(error || new Error("Spectrum unavailable"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pendingSpectrumFrameWaiters = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForSpectrumFrame(expectedCenterHz = null, timeoutMs = 1200) {
|
||||||
|
const targetCenterHz = Number(expectedCenterHz);
|
||||||
|
if (
|
||||||
|
lastSpectrumData &&
|
||||||
|
(!Number.isFinite(targetCenterHz) || Math.abs(Number(lastSpectrumData.center_hz) - targetCenterHz) < 2)
|
||||||
|
) {
|
||||||
|
return Promise.resolve(lastSpectrumData);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const waiter = {
|
||||||
|
targetCenterHz,
|
||||||
|
resolve,
|
||||||
|
reject,
|
||||||
|
timer: null,
|
||||||
|
};
|
||||||
|
waiter.timer = setTimeout(() => {
|
||||||
|
pendingSpectrumFrameWaiters = pendingSpectrumFrameWaiters.filter((entry) => entry !== waiter);
|
||||||
|
reject(new Error("Timed out waiting for spectrum frame"));
|
||||||
|
}, Math.max(200, timeoutMs));
|
||||||
|
pendingSpectrumFrameWaiters.push(waiter);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function pruneSpectrumPeakHoldFrames(now = Date.now()) {
|
function pruneSpectrumPeakHoldFrames(now = Date.now()) {
|
||||||
const holdMs = Math.max(0, Number.isFinite(overviewPeakHoldMs) ? overviewPeakHoldMs : 0);
|
const holdMs = Math.max(0, Number.isFinite(overviewPeakHoldMs) ? overviewPeakHoldMs : 0);
|
||||||
if (holdMs <= 0) {
|
if (holdMs <= 0) {
|
||||||
@@ -3975,6 +4263,7 @@ function startSpectrumStreaming() {
|
|||||||
spectrumSource = new EventSource("/spectrum");
|
spectrumSource = new EventSource("/spectrum");
|
||||||
spectrumSource.onmessage = (evt) => {
|
spectrumSource.onmessage = (evt) => {
|
||||||
if (evt.data === "null") {
|
if (evt.data === "null") {
|
||||||
|
rejectPendingSpectrumFrameWaiters(new Error("Spectrum stream reset"));
|
||||||
lastSpectrumData = null;
|
lastSpectrumData = null;
|
||||||
lastSpectrumRenderData = null;
|
lastSpectrumRenderData = null;
|
||||||
clearSpectrumPeakHoldFrames();
|
clearSpectrumPeakHoldFrames();
|
||||||
@@ -3989,6 +4278,7 @@ function startSpectrumStreaming() {
|
|||||||
try {
|
try {
|
||||||
lastSpectrumData = JSON.parse(evt.data);
|
lastSpectrumData = JSON.parse(evt.data);
|
||||||
lastSpectrumRenderData = buildSpectrumRenderData(lastSpectrumData);
|
lastSpectrumRenderData = buildSpectrumRenderData(lastSpectrumData);
|
||||||
|
settlePendingSpectrumFrameWaiters(lastSpectrumData);
|
||||||
pushSpectrumPeakHoldFrame(lastSpectrumRenderData);
|
pushSpectrumPeakHoldFrame(lastSpectrumRenderData);
|
||||||
pushOverviewWaterfallFrame(lastSpectrumData);
|
pushOverviewWaterfallFrame(lastSpectrumData);
|
||||||
refreshCenterFreqDisplay();
|
refreshCenterFreqDisplay();
|
||||||
@@ -3999,6 +4289,7 @@ function startSpectrumStreaming() {
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
};
|
};
|
||||||
spectrumSource.onerror = () => {
|
spectrumSource.onerror = () => {
|
||||||
|
rejectPendingSpectrumFrameWaiters(new Error("Spectrum stream disconnected"));
|
||||||
if (spectrumSource) {
|
if (spectrumSource) {
|
||||||
spectrumSource.close();
|
spectrumSource.close();
|
||||||
spectrumSource = null;
|
spectrumSource = null;
|
||||||
@@ -4019,6 +4310,7 @@ function stopSpectrumStreaming() {
|
|||||||
spectrumDrawPending = false;
|
spectrumDrawPending = false;
|
||||||
lastSpectrumData = null;
|
lastSpectrumData = null;
|
||||||
lastSpectrumRenderData = null;
|
lastSpectrumRenderData = null;
|
||||||
|
rejectPendingSpectrumFrameWaiters(new Error("Spectrum streaming stopped"));
|
||||||
clearSpectrumPeakHoldFrames();
|
clearSpectrumPeakHoldFrames();
|
||||||
overviewWaterfallRows = [];
|
overviewWaterfallRows = [];
|
||||||
overviewWaterfallPushCount = 0;
|
overviewWaterfallPushCount = 0;
|
||||||
|
|||||||
@@ -92,6 +92,7 @@
|
|||||||
<label id="spectrum-bw-label">Bandwidth <input type="number" id="spectrum-bw-input" value="" step="0.1" min="0.1" /> kHz</label>
|
<label id="spectrum-bw-label">Bandwidth <input type="number" id="spectrum-bw-input" value="" step="0.1" min="0.1" /> kHz</label>
|
||||||
<button id="spectrum-bw-set-btn" type="button">Set</button>
|
<button id="spectrum-bw-set-btn" type="button">Set</button>
|
||||||
<button id="spectrum-bw-auto-btn" type="button">Auto BW</button>
|
<button id="spectrum-bw-auto-btn" type="button">Auto BW</button>
|
||||||
|
<button id="spectrum-bw-sweet-btn" type="button">Sweet-spot</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="spectrum-level-row">
|
<div id="spectrum-level-row">
|
||||||
<label class="overview-control" id="spectrum-peak-hold-label">Peak Hold
|
<label class="overview-control" id="spectrum-peak-hold-label">Peak Hold
|
||||||
|
|||||||
@@ -1642,6 +1642,7 @@ button:focus-visible, input:focus-visible, select:focus-visible {
|
|||||||
#spectrum-bw-row {
|
#spectrum-bw-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
}
|
}
|
||||||
#spectrum-bw-label {
|
#spectrum-bw-label {
|
||||||
@@ -1664,7 +1665,8 @@ button:focus-visible, input:focus-visible, select:focus-visible {
|
|||||||
height: 1.5rem;
|
height: 1.5rem;
|
||||||
}
|
}
|
||||||
#spectrum-bw-set-btn,
|
#spectrum-bw-set-btn,
|
||||||
#spectrum-bw-auto-btn {
|
#spectrum-bw-auto-btn,
|
||||||
|
#spectrum-bw-sweet-btn {
|
||||||
height: 1.5rem;
|
height: 1.5rem;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
@@ -1765,6 +1767,7 @@ button:focus-visible, input:focus-visible, select:focus-visible {
|
|||||||
}
|
}
|
||||||
#spectrum-bw-set-btn,
|
#spectrum-bw-set-btn,
|
||||||
#spectrum-bw-auto-btn,
|
#spectrum-bw-auto-btn,
|
||||||
|
#spectrum-bw-sweet-btn,
|
||||||
#spectrum-auto-btn {
|
#spectrum-auto-btn {
|
||||||
height: 2.2rem;
|
height: 2.2rem;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user