Complete TypeScript frontend migration #22
@@ -1,386 +1,397 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
const cwStatusEl = document.getElementById("cw-status");
|
(() => {
|
||||||
const cwOutputEl = document.getElementById("cw-output");
|
// src/plugins/cw.ts
|
||||||
const cwAutoInput = document.getElementById("cw-auto");
|
var cwWindow = window;
|
||||||
const cwWpmInput = document.getElementById("cw-wpm");
|
var cwStatusEl = document.getElementById("cw-status");
|
||||||
const cwToneInput = document.getElementById("cw-tone");
|
var cwOutputEl = document.getElementById("cw-output");
|
||||||
const cwSignalIndicator = document.getElementById("cw-signal-indicator");
|
var cwAutoInput = document.getElementById("cw-auto");
|
||||||
const cwToneCanvas = document.getElementById("cw-tone-waterfall");
|
var cwWpmInput = document.getElementById("cw-wpm");
|
||||||
const cwToneGl = typeof createTrxWebGlRenderer === "function" ? createTrxWebGlRenderer(cwToneCanvas, { alpha: true }) : null;
|
var cwToneInput = document.getElementById("cw-tone");
|
||||||
const cwTonePickerEl = document.querySelector(".cw-tone-picker");
|
var cwSignalIndicator = document.getElementById("cw-signal-indicator");
|
||||||
const cwToneRangeEl = document.getElementById("cw-tone-range");
|
var cwToneCanvas = document.getElementById("cw-tone-waterfall");
|
||||||
const cwBarOverlay = document.getElementById("cw-bar-overlay");
|
var cwToneGl = cwToneCanvas && cwWindow.createTrxWebGlRenderer ? cwWindow.createTrxWebGlRenderer(cwToneCanvas, { alpha: true }) : null;
|
||||||
const CW_MAX_LINES = 200;
|
var cwTonePickerEl = document.querySelector(".cw-tone-picker");
|
||||||
const CW_TONE_MIN_HZ = 100;
|
var cwToneRangeEl = document.getElementById("cw-tone-range");
|
||||||
const CW_TONE_MAX_HZ = 1e4;
|
var cwBarOverlay = document.getElementById("cw-bar-overlay");
|
||||||
const CW_WPM_MIN = 5;
|
var CW_MAX_LINES = 200;
|
||||||
const CW_WPM_MAX = 40;
|
var CW_TONE_MIN_HZ = 100;
|
||||||
const CW_BAR_WINDOW_MS = 15 * 60 * 1e3;
|
var CW_TONE_MAX_HZ = 1e4;
|
||||||
const CW_BAR_LINE_GAP_MS = 5e3;
|
var CW_WPM_MIN = 5;
|
||||||
let cwLastAppendTime = 0;
|
var CW_WPM_MAX = 40;
|
||||||
let cwTonePickerRaf = null;
|
var CW_BAR_WINDOW_MS = 15 * 60 * 1e3;
|
||||||
let cwBarHistory = [];
|
var CW_BAR_LINE_GAP_MS = 5e3;
|
||||||
let cwBarCurrentLine = null;
|
var cwLastAppendTime = 0;
|
||||||
let cwBarDismissedAtMs = 0;
|
var cwTonePickerRaf = null;
|
||||||
let cwAutoLocalOverride = null;
|
var cwBarHistory = [];
|
||||||
function applyCwAutoUi(enabled) {
|
var cwBarCurrentLine = null;
|
||||||
if (cwAutoInput) cwAutoInput.checked = enabled;
|
var cwBarDismissedAtMs = 0;
|
||||||
|
var cwAutoLocalOverride = null;
|
||||||
|
function escapeCwHtml(input) {
|
||||||
|
return cwWindow.escapeMapHtml?.(input) ?? input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
||||||
|
}
|
||||||
|
function applyCwAutoUi(enabled) {
|
||||||
|
if (cwAutoInput) cwAutoInput.checked = enabled;
|
||||||
|
if (cwWpmInput) {
|
||||||
|
cwWpmInput.disabled = enabled;
|
||||||
|
cwWpmInput.readOnly = enabled;
|
||||||
|
}
|
||||||
|
if (cwToneInput) {
|
||||||
|
cwToneInput.disabled = enabled;
|
||||||
|
cwToneInput.readOnly = enabled;
|
||||||
|
}
|
||||||
|
if (cwTonePickerEl) {
|
||||||
|
cwTonePickerEl.classList.toggle("is-auto", enabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cwWindow.applyCwAutoUi = applyCwAutoUi;
|
||||||
|
cwWindow.applyCwAutoUiFromServer = function(enabled) {
|
||||||
|
if (cwAutoLocalOverride !== null) return;
|
||||||
|
applyCwAutoUi(enabled);
|
||||||
|
};
|
||||||
|
function cwBarFlushCurrentLine() {
|
||||||
|
if (cwBarCurrentLine && cwBarCurrentLine.text.trim()) {
|
||||||
|
cwBarHistory.unshift(cwBarCurrentLine);
|
||||||
|
if (cwBarHistory.length > 50) cwBarHistory.length = 50;
|
||||||
|
}
|
||||||
|
cwBarCurrentLine = null;
|
||||||
|
}
|
||||||
|
function updateCwBar() {
|
||||||
|
if (!cwBarOverlay) return;
|
||||||
|
const mode = (document.getElementById("mode")?.value || "").toUpperCase();
|
||||||
|
const isCw = mode === "CW" || mode === "CWR";
|
||||||
|
const cutoffMs = Date.now() - CW_BAR_WINDOW_MS;
|
||||||
|
const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs);
|
||||||
|
const liveLines = cwBarCurrentLine && cwBarCurrentLine.text ? [cwBarCurrentLine, ...recent] : recent;
|
||||||
|
const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, line.tsMs || 0), 0);
|
||||||
|
if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) {
|
||||||
|
cwBarOverlay.style.display = "none";
|
||||||
|
cwBarOverlay.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let html = `<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">CW</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-actions"><span class="aprs-bar-window">Last 15 minutes</span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearCwBar()" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();window.clearCwBar();}" aria-label="Clear CW overlay">Clear</span></span><button class="aprs-bar-close" type="button" onclick="window.closeCwBar()" aria-label="Close CW overlay">×</button></span></div>`;
|
||||||
|
for (const line of liveLines.slice(0, 8)) {
|
||||||
|
const ts = line.ts ? `<span class="aprs-bar-time">${line.ts}</span>` : "";
|
||||||
|
const meta = [
|
||||||
|
line.wpm ? `${String(line.wpm)} WPM` : null,
|
||||||
|
line.tone_hz ? `${String(line.tone_hz)} Hz` : null
|
||||||
|
].filter(Boolean).join(" · ");
|
||||||
|
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}${escapeCwHtml(line.text)}` + (meta ? ` <span class="aprs-bar-time">${escapeCwHtml(meta)}</span>` : "") + `</div></div>`;
|
||||||
|
}
|
||||||
|
cwBarOverlay.innerHTML = html;
|
||||||
|
cwBarOverlay.style.display = "flex";
|
||||||
|
}
|
||||||
|
cwWindow.updateCwBar = updateCwBar;
|
||||||
|
cwWindow.clearCwBar = function() {
|
||||||
|
cwWindow.resetCwHistoryView?.();
|
||||||
|
};
|
||||||
|
cwWindow.closeCwBar = function() {
|
||||||
|
cwBarDismissedAtMs = Date.now();
|
||||||
|
if (cwBarOverlay) {
|
||||||
|
cwBarOverlay.style.display = "none";
|
||||||
|
cwBarOverlay.innerHTML = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
function clampCwWpm(wpm) {
|
||||||
|
const numeric = Number(wpm);
|
||||||
|
if (!Number.isFinite(numeric)) return 15;
|
||||||
|
return Math.round(Math.max(CW_WPM_MIN, Math.min(CW_WPM_MAX, numeric)));
|
||||||
|
}
|
||||||
|
function clampCwTone(tone) {
|
||||||
|
const numeric = Number(tone);
|
||||||
|
if (!Number.isFinite(numeric)) return 700;
|
||||||
|
return Math.round(Math.max(CW_TONE_MIN_HZ, Math.min(CW_TONE_MAX_HZ, numeric)));
|
||||||
|
}
|
||||||
|
function currentCwToneRange() {
|
||||||
|
const tunedHz = Number.isFinite(cwWindow.lastFreqHz) ? Number(cwWindow.lastFreqHz) : NaN;
|
||||||
|
const bandwidthHz = Number.isFinite(cwWindow.currentBandwidthHz) ? Number(cwWindow.currentBandwidthHz) : NaN;
|
||||||
|
if (!Number.isFinite(tunedHz) || !Number.isFinite(bandwidthHz) || bandwidthHz <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const mode = (document.getElementById("mode")?.value || "").toUpperCase();
|
||||||
|
const lowerSideband = mode === "CWR";
|
||||||
|
const upperSideband = mode === "CW";
|
||||||
|
if (!lowerSideband && !upperSideband) return null;
|
||||||
|
const toneMinHz = CW_TONE_MIN_HZ;
|
||||||
|
const toneMaxHz = CW_TONE_MAX_HZ;
|
||||||
|
return {
|
||||||
|
tunedHz,
|
||||||
|
bandwidthHz,
|
||||||
|
toneMinHz,
|
||||||
|
toneMaxHz,
|
||||||
|
toneSpanHz: Math.max(1, toneMaxHz - toneMinHz),
|
||||||
|
lowerSideband,
|
||||||
|
mode
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function cwToneToRfHz(range, toneHz) {
|
||||||
|
if (!range) return NaN;
|
||||||
|
return range.lowerSideband ? range.tunedHz - toneHz : range.tunedHz + toneHz;
|
||||||
|
}
|
||||||
|
function toneClampForRange(tone, range) {
|
||||||
|
const clamped = clampCwTone(tone);
|
||||||
|
if (!range) return clamped;
|
||||||
|
return Math.max(range.toneMinHz, Math.min(range.toneMaxHz, clamped));
|
||||||
|
}
|
||||||
|
function ensureCwToneCanvasResolution() {
|
||||||
|
if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return false;
|
||||||
|
const rect = cwToneCanvas.getBoundingClientRect();
|
||||||
|
const cssWidth = Math.round(rect.width);
|
||||||
|
const cssHeight = Math.round(rect.height);
|
||||||
|
if (cssWidth < 8 || cssHeight < 8) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
return cwToneGl.ensureSize(cssWidth, cssHeight, dpr);
|
||||||
|
}
|
||||||
|
function drawCwTonePicker() {
|
||||||
|
if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return;
|
||||||
|
ensureCwToneCanvasResolution();
|
||||||
|
if (cwToneCanvas.width < 8 || cwToneCanvas.height < 8) return;
|
||||||
|
const width = cwToneCanvas.width;
|
||||||
|
const height = cwToneCanvas.height;
|
||||||
|
cwToneGl.clear([0, 0, 0, 0]);
|
||||||
|
const range = currentCwToneRange();
|
||||||
|
if (!cwWindow.lastSpectrumData || !Array.isArray(cwWindow.lastSpectrumData.bins) || !cwWindow.lastSpectrumData.bins.length || !range) {
|
||||||
|
if (cwToneRangeEl) {
|
||||||
|
const mode = (document.getElementById("mode")?.value || "").toUpperCase();
|
||||||
|
if (mode !== "CW" && mode !== "CWR") {
|
||||||
|
cwToneRangeEl.textContent = "CW/CWR mode required";
|
||||||
|
} else if (!cwWindow.lastSpectrumData || !Array.isArray(cwWindow.lastSpectrumData.bins) || !cwWindow.lastSpectrumData.bins.length) {
|
||||||
|
cwToneRangeEl.textContent = "Waiting for spectrum";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cwToneGl.fillRect(0, 0, width, height, [130 / 255, 150 / 255, 165 / 255, 0.22]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cwToneRangeEl) {
|
||||||
|
const side = range.lowerSideband ? "Lower side" : "Upper side";
|
||||||
|
cwToneRangeEl.textContent = `Audio ${String(range.toneMinHz)}-${String(range.toneMaxHz)} Hz · ${side}`;
|
||||||
|
}
|
||||||
|
const bins = cwWindow.lastSpectrumData.bins;
|
||||||
|
const sampleRate = cwWindow.lastSpectrumData.sample_rate;
|
||||||
|
const centerHz = cwWindow.lastSpectrumData.center_hz;
|
||||||
|
const maxIdx = Math.max(1, bins.length - 1);
|
||||||
|
const fullLoHz = centerHz - sampleRate / 2;
|
||||||
|
const tones = new Array(width).fill(-140);
|
||||||
|
for (let x = 0; x < width; x += 1) {
|
||||||
|
const frac = width <= 1 ? 0 : x / (width - 1);
|
||||||
|
const toneHz = range.toneMinHz + frac * range.toneSpanHz;
|
||||||
|
const rfHz = cwToneToRfHz(range, toneHz);
|
||||||
|
const idx = Math.max(0, Math.min(maxIdx, Math.round((rfHz - fullLoHz) / sampleRate * maxIdx)));
|
||||||
|
const power = Number.isFinite(Number(bins[idx])) ? Number(bins[idx]) : -140;
|
||||||
|
tones[x] = power;
|
||||||
|
}
|
||||||
|
const smoothed = new Array(width).fill(-140);
|
||||||
|
const smoothRadius = Math.max(1, Math.round(width / 180));
|
||||||
|
for (let x = 0; x < width; x += 1) {
|
||||||
|
let sum = 0;
|
||||||
|
let count = 0;
|
||||||
|
for (let i = x - smoothRadius; i <= x + smoothRadius; i += 1) {
|
||||||
|
if (i < 0 || i >= width) continue;
|
||||||
|
sum += tones[i] ?? -140;
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
smoothed[x] = count > 0 ? sum / count : tones[x] ?? -140;
|
||||||
|
}
|
||||||
|
const sorted = smoothed.slice().sort((a, b) => a - b);
|
||||||
|
const q20 = sorted[Math.floor((sorted.length - 1) * 0.2)] ?? -120;
|
||||||
|
const q95 = sorted[Math.floor((sorted.length - 1) * 0.95)] ?? -70;
|
||||||
|
const floorDb = Math.min(q20 - 2, q95 - 10);
|
||||||
|
const ceilDb = Math.max(floorDb + 18, q95 + 2);
|
||||||
|
const dbSpan = Math.max(1, ceilDb - floorDb);
|
||||||
|
const yForDb = (db) => {
|
||||||
|
const n = Math.max(0, Math.min(1, (db - floorDb) / dbSpan));
|
||||||
|
return Math.round((1 - n) * (height - 1));
|
||||||
|
};
|
||||||
|
const rootStyle = getComputedStyle(document.documentElement);
|
||||||
|
const accent = (rootStyle.getPropertyValue("--accent-green") || "").trim() || "#00d17f";
|
||||||
|
const parseColor = typeof cwWindow.trxParseCssColor === "function" ? cwWindow.trxParseCssColor : null;
|
||||||
|
const accentRgba = parseColor ? parseColor(accent) : [0, 0.82, 0.5, 1];
|
||||||
|
const axisColor = [230 / 255, 235 / 255, 245 / 255, 0.15];
|
||||||
|
cwToneGl.fillRect(0, 0, width, height, [7 / 255, 12 / 255, 18 / 255, 0.94]);
|
||||||
|
const hGridCount = 4;
|
||||||
|
const gridSegments = [];
|
||||||
|
for (let i = 1; i <= hGridCount; i += 1) {
|
||||||
|
const y = Math.round(i / (hGridCount + 1) * (height - 1));
|
||||||
|
gridSegments.push(0, y, width, y);
|
||||||
|
}
|
||||||
|
cwToneGl.drawSegments(gridSegments, axisColor, 1);
|
||||||
|
const toneStep = range.toneSpanHz <= 500 ? 50 : range.toneSpanHz <= 1e3 ? 100 : 200;
|
||||||
|
const firstTick = Math.ceil(range.toneMinHz / toneStep) * toneStep;
|
||||||
|
const tickSegments = [];
|
||||||
|
for (let tone = firstTick; tone <= range.toneMaxHz; tone += toneStep) {
|
||||||
|
const frac = (tone - range.toneMinHz) / range.toneSpanHz;
|
||||||
|
const x = Math.max(0, Math.min(width - 1, Math.round(frac * (width - 1))));
|
||||||
|
tickSegments.push(x, 0, x, height);
|
||||||
|
}
|
||||||
|
cwToneGl.drawSegments(tickSegments, axisColor, 1);
|
||||||
|
const linePoints = [];
|
||||||
|
for (let x = 0; x < width; x += 1) {
|
||||||
|
linePoints.push(x, yForDb(smoothed[x] ?? -140));
|
||||||
|
}
|
||||||
|
cwToneGl.drawFilledArea(linePoints, height, [accentRgba[0], accentRgba[1], accentRgba[2], 0.24]);
|
||||||
|
cwToneGl.drawPolyline(linePoints, accentRgba, Math.max(1.2, (window.devicePixelRatio || 1) * 1.2));
|
||||||
|
const currentTone = toneClampForRange(cwToneInput ? cwToneInput.value : 700, range);
|
||||||
|
const markerFrac = (currentTone - range.toneMinHz) / range.toneSpanHz;
|
||||||
|
const markerX = Math.max(0, Math.min(width - 1, Math.round(markerFrac * (width - 1))));
|
||||||
|
const markerY = yForDb(smoothed[Math.max(0, Math.min(width - 1, markerX))] ?? -140);
|
||||||
|
cwToneGl.drawSegments([markerX, 0, markerX, height], [1, 1, 1, 0.9], 1.5);
|
||||||
|
cwToneGl.drawPoints([markerX, markerY], Math.max(2, Math.round(height * 0.055)), [1, 1, 1, 0.9]);
|
||||||
|
if (cwAutoInput?.checked) {
|
||||||
|
cwToneGl.fillRect(0, 0, width, height, [0, 0, 0, 0.22]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function setCwTone(tone, { syncInput = true } = {}) {
|
||||||
|
const range = currentCwToneRange();
|
||||||
|
const clamped = toneClampForRange(tone, range);
|
||||||
|
if (cwToneInput && syncInput) {
|
||||||
|
cwToneInput.value = String(clamped);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await cwWindow.postPath?.(`/set_cw_tone?tone_hz=${encodeURIComponent(clamped)}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("CW tone set failed", e);
|
||||||
|
}
|
||||||
|
drawCwTonePicker();
|
||||||
|
}
|
||||||
|
if (cwAutoInput) {
|
||||||
|
cwAutoInput.addEventListener("change", () => {
|
||||||
|
void (async () => {
|
||||||
|
const enabled = cwAutoInput.checked;
|
||||||
|
cwAutoLocalOverride = enabled;
|
||||||
|
applyCwAutoUi(enabled);
|
||||||
|
try {
|
||||||
|
await cwWindow.postPath?.(`/set_cw_auto?enabled=${enabled ? "true" : "false"}`);
|
||||||
|
drawCwTonePicker();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("CW auto toggle failed", error);
|
||||||
|
} finally {
|
||||||
|
cwAutoLocalOverride = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
}
|
||||||
if (cwWpmInput) {
|
if (cwWpmInput) {
|
||||||
cwWpmInput.disabled = enabled;
|
cwWpmInput.addEventListener("change", () => {
|
||||||
cwWpmInput.readOnly = enabled;
|
void (async () => {
|
||||||
|
if (cwAutoInput?.checked) return;
|
||||||
|
const wpm = clampCwWpm(cwWpmInput.value);
|
||||||
|
cwWpmInput.value = String(wpm);
|
||||||
|
try {
|
||||||
|
await cwWindow.postPath?.(`/set_cw_wpm?wpm=${encodeURIComponent(wpm)}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("CW WPM set failed", error);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (cwToneInput) {
|
if (cwToneInput) {
|
||||||
cwToneInput.disabled = enabled;
|
cwToneInput.addEventListener("change", () => {
|
||||||
cwToneInput.readOnly = enabled;
|
if (!cwAutoInput?.checked) void setCwTone(cwToneInput.value);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (cwTonePickerEl) {
|
if (cwToneCanvas) {
|
||||||
cwTonePickerEl.classList.toggle("is-auto", enabled);
|
cwToneCanvas.addEventListener("click", (event) => {
|
||||||
|
if (cwAutoInput?.checked) return;
|
||||||
|
const rect = cwToneCanvas.getBoundingClientRect();
|
||||||
|
if (rect.width <= 0) return;
|
||||||
|
const range = currentCwToneRange();
|
||||||
|
if (!range) return;
|
||||||
|
const frac = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
|
||||||
|
const tone = range.toneMinHz + frac * range.toneSpanHz;
|
||||||
|
void setCwTone(tone);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
cwWindow.resetCwHistoryView = function() {
|
||||||
window.applyCwAutoUi = applyCwAutoUi;
|
if (cwOutputEl) cwOutputEl.innerHTML = "";
|
||||||
window.applyCwAutoUiFromServer = function(enabled) {
|
cwLastAppendTime = 0;
|
||||||
if (cwAutoLocalOverride !== null) return;
|
cwBarHistory = [];
|
||||||
applyCwAutoUi(enabled);
|
cwBarCurrentLine = null;
|
||||||
};
|
|
||||||
function cwBarFlushCurrentLine() {
|
|
||||||
if (cwBarCurrentLine && cwBarCurrentLine.text.trim()) {
|
|
||||||
cwBarHistory.unshift(cwBarCurrentLine);
|
|
||||||
if (cwBarHistory.length > 50) cwBarHistory.length = 50;
|
|
||||||
}
|
|
||||||
cwBarCurrentLine = null;
|
|
||||||
}
|
|
||||||
function updateCwBar() {
|
|
||||||
if (!cwBarOverlay) return;
|
|
||||||
const mode = (document.getElementById("mode")?.value || "").toUpperCase();
|
|
||||||
const isCw = mode === "CW" || mode === "CWR";
|
|
||||||
const cutoffMs = Date.now() - CW_BAR_WINDOW_MS;
|
|
||||||
const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs);
|
|
||||||
const liveLines = cwBarCurrentLine && cwBarCurrentLine.text ? [cwBarCurrentLine, ...recent] : recent;
|
|
||||||
const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, Number(line.tsMs) || 0), 0);
|
|
||||||
if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) {
|
|
||||||
cwBarOverlay.style.display = "none";
|
|
||||||
cwBarOverlay.innerHTML = "";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let html = `<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">CW</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-actions"><span class="aprs-bar-window">Last 15 minutes</span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearCwBar()" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();window.clearCwBar();}" aria-label="Clear CW overlay">Clear</span></span><button class="aprs-bar-close" type="button" onclick="window.closeCwBar()" aria-label="Close CW overlay">×</button></span></div>`;
|
|
||||||
for (const line of liveLines.slice(0, 8)) {
|
|
||||||
const ts = line.ts ? `<span class="aprs-bar-time">${line.ts}</span>` : "";
|
|
||||||
const meta = [
|
|
||||||
line.wpm ? `${line.wpm} WPM` : null,
|
|
||||||
line.tone_hz ? `${line.tone_hz} Hz` : null
|
|
||||||
].filter(Boolean).join(" · ");
|
|
||||||
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}${escapeMapHtml(line.text)}` + (meta ? ` <span class="aprs-bar-time">${escapeMapHtml(meta)}</span>` : "") + `</div></div>`;
|
|
||||||
}
|
|
||||||
cwBarOverlay.innerHTML = html;
|
|
||||||
cwBarOverlay.style.display = "flex";
|
|
||||||
}
|
|
||||||
window.updateCwBar = updateCwBar;
|
|
||||||
window.clearCwBar = function() {
|
|
||||||
window.resetCwHistoryView();
|
|
||||||
};
|
|
||||||
window.closeCwBar = function() {
|
|
||||||
cwBarDismissedAtMs = Date.now();
|
|
||||||
if (cwBarOverlay) {
|
|
||||||
cwBarOverlay.style.display = "none";
|
|
||||||
cwBarOverlay.innerHTML = "";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
function clampCwWpm(wpm) {
|
|
||||||
const numeric = Number(wpm);
|
|
||||||
if (!Number.isFinite(numeric)) return 15;
|
|
||||||
return Math.round(Math.max(CW_WPM_MIN, Math.min(CW_WPM_MAX, numeric)));
|
|
||||||
}
|
|
||||||
function clampCwTone(tone) {
|
|
||||||
const numeric = Number(tone);
|
|
||||||
if (!Number.isFinite(numeric)) return 700;
|
|
||||||
return Math.round(Math.max(CW_TONE_MIN_HZ, Math.min(CW_TONE_MAX_HZ, numeric)));
|
|
||||||
}
|
|
||||||
function currentCwToneRange() {
|
|
||||||
const tunedHz = Number.isFinite(window.lastFreqHz) ? Number(window.lastFreqHz) : NaN;
|
|
||||||
const bandwidthHz = Number.isFinite(window.currentBandwidthHz) ? Number(window.currentBandwidthHz) : NaN;
|
|
||||||
if (!Number.isFinite(tunedHz) || !Number.isFinite(bandwidthHz) || bandwidthHz <= 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const mode = String(document.getElementById("mode")?.value || "").toUpperCase();
|
|
||||||
const lowerSideband = mode === "CWR";
|
|
||||||
const upperSideband = mode === "CW";
|
|
||||||
if (!lowerSideband && !upperSideband) return null;
|
|
||||||
const toneMinHz = CW_TONE_MIN_HZ;
|
|
||||||
const toneMaxHz = CW_TONE_MAX_HZ;
|
|
||||||
if (toneMaxHz < toneMinHz) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
tunedHz,
|
|
||||||
bandwidthHz,
|
|
||||||
toneMinHz,
|
|
||||||
toneMaxHz,
|
|
||||||
toneSpanHz: Math.max(1, toneMaxHz - toneMinHz),
|
|
||||||
lowerSideband,
|
|
||||||
mode
|
|
||||||
};
|
|
||||||
}
|
|
||||||
function cwToneToRfHz(range, toneHz) {
|
|
||||||
if (!range) return NaN;
|
|
||||||
return range.lowerSideband ? range.tunedHz - toneHz : range.tunedHz + toneHz;
|
|
||||||
}
|
|
||||||
function toneClampForRange(tone, range) {
|
|
||||||
const clamped = clampCwTone(tone);
|
|
||||||
if (!range) return clamped;
|
|
||||||
return Math.max(range.toneMinHz, Math.min(range.toneMaxHz, clamped));
|
|
||||||
}
|
|
||||||
function ensureCwToneCanvasResolution() {
|
|
||||||
if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return false;
|
|
||||||
const rect = cwToneCanvas.getBoundingClientRect();
|
|
||||||
const cssWidth = Math.round(rect.width);
|
|
||||||
const cssHeight = Math.round(rect.height);
|
|
||||||
if (cssWidth < 8 || cssHeight < 8) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const dpr = window.devicePixelRatio || 1;
|
|
||||||
return cwToneGl.ensureSize(cssWidth, cssHeight, dpr);
|
|
||||||
}
|
|
||||||
function drawCwTonePicker() {
|
|
||||||
if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return;
|
|
||||||
ensureCwToneCanvasResolution();
|
|
||||||
if (cwToneCanvas.width < 8 || cwToneCanvas.height < 8) return;
|
|
||||||
const width = cwToneCanvas.width;
|
|
||||||
const height = cwToneCanvas.height;
|
|
||||||
cwToneGl.clear([0, 0, 0, 0]);
|
|
||||||
const range = currentCwToneRange();
|
|
||||||
if (!window.lastSpectrumData || !Array.isArray(window.lastSpectrumData.bins) || !window.lastSpectrumData.bins.length || !range) {
|
|
||||||
if (cwToneRangeEl) {
|
|
||||||
const mode = String(document.getElementById("mode")?.value || "").toUpperCase();
|
|
||||||
if (mode !== "CW" && mode !== "CWR") {
|
|
||||||
cwToneRangeEl.textContent = "CW/CWR mode required";
|
|
||||||
} else if (!window.lastSpectrumData || !Array.isArray(window.lastSpectrumData.bins) || !window.lastSpectrumData.bins.length) {
|
|
||||||
cwToneRangeEl.textContent = "Waiting for spectrum";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cwToneGl.fillRect(0, 0, width, height, [130 / 255, 150 / 255, 165 / 255, 0.22]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (cwToneRangeEl) {
|
|
||||||
const side = range.lowerSideband ? "Lower side" : "Upper side";
|
|
||||||
cwToneRangeEl.textContent = `Audio ${range.toneMinHz}-${range.toneMaxHz} Hz · ${side}`;
|
|
||||||
}
|
|
||||||
const bins = window.lastSpectrumData.bins;
|
|
||||||
const sampleRate = Number(window.lastSpectrumData.sample_rate);
|
|
||||||
const centerHz = Number(window.lastSpectrumData.center_hz);
|
|
||||||
const maxIdx = Math.max(1, bins.length - 1);
|
|
||||||
const fullLoHz = centerHz - sampleRate / 2;
|
|
||||||
const tones = new Array(width).fill(-140);
|
|
||||||
for (let x = 0; x < width; x += 1) {
|
|
||||||
const frac = width <= 1 ? 0 : x / (width - 1);
|
|
||||||
const toneHz = range.toneMinHz + frac * range.toneSpanHz;
|
|
||||||
const rfHz = cwToneToRfHz(range, toneHz);
|
|
||||||
const idx = Math.max(0, Math.min(maxIdx, Math.round((rfHz - fullLoHz) / sampleRate * maxIdx)));
|
|
||||||
const power = Number.isFinite(Number(bins[idx])) ? Number(bins[idx]) : -140;
|
|
||||||
tones[x] = power;
|
|
||||||
}
|
|
||||||
const smoothed = new Array(width).fill(-140);
|
|
||||||
const smoothRadius = Math.max(1, Math.round(width / 180));
|
|
||||||
for (let x = 0; x < width; x += 1) {
|
|
||||||
let sum = 0;
|
|
||||||
let count = 0;
|
|
||||||
for (let i = x - smoothRadius; i <= x + smoothRadius; i += 1) {
|
|
||||||
if (i < 0 || i >= width) continue;
|
|
||||||
sum += tones[i];
|
|
||||||
count += 1;
|
|
||||||
}
|
|
||||||
smoothed[x] = count > 0 ? sum / count : tones[x];
|
|
||||||
}
|
|
||||||
const sorted = smoothed.slice().sort((a, b) => a - b);
|
|
||||||
const q20 = sorted[Math.floor((sorted.length - 1) * 0.2)] ?? -120;
|
|
||||||
const q95 = sorted[Math.floor((sorted.length - 1) * 0.95)] ?? -70;
|
|
||||||
const floorDb = Math.min(q20 - 2, q95 - 10);
|
|
||||||
const ceilDb = Math.max(floorDb + 18, q95 + 2);
|
|
||||||
const dbSpan = Math.max(1, ceilDb - floorDb);
|
|
||||||
const yForDb = (db) => {
|
|
||||||
const n = Math.max(0, Math.min(1, (db - floorDb) / dbSpan));
|
|
||||||
return Math.round((1 - n) * (height - 1));
|
|
||||||
};
|
|
||||||
const rootStyle = getComputedStyle(document.documentElement);
|
|
||||||
const accent = (rootStyle.getPropertyValue("--accent-green") || "").trim() || "#00d17f";
|
|
||||||
const parseColor = typeof window.trxParseCssColor === "function" ? window.trxParseCssColor : null;
|
|
||||||
const accentRgba = parseColor ? parseColor(accent) : [0, 0.82, 0.5, 1];
|
|
||||||
const axisColor = [230 / 255, 235 / 255, 245 / 255, 0.15];
|
|
||||||
cwToneGl.fillRect(0, 0, width, height, [7 / 255, 12 / 255, 18 / 255, 0.94]);
|
|
||||||
const hGridCount = 4;
|
|
||||||
const gridSegments = [];
|
|
||||||
for (let i = 1; i <= hGridCount; i += 1) {
|
|
||||||
const y = Math.round(i / (hGridCount + 1) * (height - 1));
|
|
||||||
gridSegments.push(0, y, width, y);
|
|
||||||
}
|
|
||||||
cwToneGl.drawSegments(gridSegments, axisColor, 1);
|
|
||||||
const toneStep = range.toneSpanHz <= 500 ? 50 : range.toneSpanHz <= 1e3 ? 100 : 200;
|
|
||||||
const firstTick = Math.ceil(range.toneMinHz / toneStep) * toneStep;
|
|
||||||
const tickSegments = [];
|
|
||||||
for (let tone = firstTick; tone <= range.toneMaxHz; tone += toneStep) {
|
|
||||||
const frac = (tone - range.toneMinHz) / range.toneSpanHz;
|
|
||||||
const x = Math.max(0, Math.min(width - 1, Math.round(frac * (width - 1))));
|
|
||||||
tickSegments.push(x, 0, x, height);
|
|
||||||
}
|
|
||||||
cwToneGl.drawSegments(tickSegments, axisColor, 1);
|
|
||||||
const linePoints = [];
|
|
||||||
for (let x = 0; x < width; x += 1) {
|
|
||||||
linePoints.push(x, yForDb(smoothed[x]));
|
|
||||||
}
|
|
||||||
cwToneGl.drawFilledArea(linePoints, height, [accentRgba[0], accentRgba[1], accentRgba[2], 0.24]);
|
|
||||||
cwToneGl.drawPolyline(linePoints, accentRgba, Math.max(1.2, (window.devicePixelRatio || 1) * 1.2));
|
|
||||||
const currentTone = toneClampForRange(cwToneInput ? cwToneInput.value : 700, range);
|
|
||||||
const markerFrac = (currentTone - range.toneMinHz) / range.toneSpanHz;
|
|
||||||
const markerX = Math.max(0, Math.min(width - 1, Math.round(markerFrac * (width - 1))));
|
|
||||||
const markerY = yForDb(smoothed[Math.max(0, Math.min(width - 1, markerX))]);
|
|
||||||
cwToneGl.drawSegments([markerX, 0, markerX, height], [1, 1, 1, 0.9], 1.5);
|
|
||||||
cwToneGl.drawPoints([markerX, markerY], Math.max(2, Math.round(height * 0.055)), [1, 1, 1, 0.9]);
|
|
||||||
if (cwAutoInput?.checked) {
|
|
||||||
cwToneGl.fillRect(0, 0, width, height, [0, 0, 0, 0.22]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async function setCwTone(tone, { syncInput = true } = {}) {
|
|
||||||
const range = currentCwToneRange();
|
|
||||||
const clamped = toneClampForRange(tone, range);
|
|
||||||
if (cwToneInput && syncInput) {
|
|
||||||
cwToneInput.value = clamped;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await postPath(`/set_cw_tone?tone_hz=${encodeURIComponent(clamped)}`);
|
|
||||||
} catch (e) {
|
|
||||||
console.error("CW tone set failed", e);
|
|
||||||
}
|
|
||||||
drawCwTonePicker();
|
|
||||||
}
|
|
||||||
if (cwAutoInput) {
|
|
||||||
cwAutoInput.addEventListener("change", async () => {
|
|
||||||
const enabled = cwAutoInput.checked;
|
|
||||||
cwAutoLocalOverride = enabled;
|
|
||||||
applyCwAutoUi(enabled);
|
|
||||||
try {
|
|
||||||
await postPath(`/set_cw_auto?enabled=${enabled ? "true" : "false"}`);
|
|
||||||
drawCwTonePicker();
|
|
||||||
} catch (e) {
|
|
||||||
console.error("CW auto toggle failed", e);
|
|
||||||
} finally {
|
|
||||||
cwAutoLocalOverride = null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (cwWpmInput) {
|
|
||||||
cwWpmInput.addEventListener("change", async () => {
|
|
||||||
if (cwAutoInput && cwAutoInput.checked) return;
|
|
||||||
const wpm = clampCwWpm(cwWpmInput.value);
|
|
||||||
cwWpmInput.value = wpm;
|
|
||||||
try {
|
|
||||||
await postPath(`/set_cw_wpm?wpm=${encodeURIComponent(wpm)}`);
|
|
||||||
} catch (e) {
|
|
||||||
console.error("CW WPM set failed", e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (cwToneInput) {
|
|
||||||
cwToneInput.addEventListener("change", async () => {
|
|
||||||
if (cwAutoInput?.checked) return;
|
|
||||||
await setCwTone(cwToneInput.value);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (cwToneCanvas) {
|
|
||||||
cwToneCanvas.addEventListener("click", async (event) => {
|
|
||||||
if (cwAutoInput?.checked) return;
|
|
||||||
const rect = cwToneCanvas.getBoundingClientRect();
|
|
||||||
if (rect.width <= 0) return;
|
|
||||||
const range = currentCwToneRange();
|
|
||||||
if (!range) return;
|
|
||||||
const frac = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
|
|
||||||
const tone = range.toneMinHz + frac * range.toneSpanHz;
|
|
||||||
await setCwTone(tone);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
window.resetCwHistoryView = function() {
|
|
||||||
if (cwOutputEl) cwOutputEl.innerHTML = "";
|
|
||||||
cwLastAppendTime = 0;
|
|
||||||
cwBarHistory = [];
|
|
||||||
cwBarCurrentLine = null;
|
|
||||||
updateCwBar();
|
|
||||||
drawCwTonePicker();
|
|
||||||
};
|
|
||||||
document.getElementById("settings-clear-cw-history")?.addEventListener("click", async () => {
|
|
||||||
if (!await window.trxUi.confirm({ title: "Clear CW history?", message: "All stored CW decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
|
|
||||||
try {
|
|
||||||
await postPath("/clear_cw_decode");
|
|
||||||
window.resetCwHistoryView();
|
|
||||||
} catch (e) {
|
|
||||||
console.error("CW history clear failed", e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
window.onServerCw = function(evt) {
|
|
||||||
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
|
|
||||||
if (evt.text && cwOutputEl) {
|
|
||||||
const now = Date.now();
|
|
||||||
if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 1e4 || evt.text === "\n") {
|
|
||||||
const line = document.createElement("div");
|
|
||||||
line.className = "cw-line";
|
|
||||||
cwOutputEl.appendChild(line);
|
|
||||||
}
|
|
||||||
cwLastAppendTime = now;
|
|
||||||
const lastLine = cwOutputEl.lastElementChild;
|
|
||||||
if (lastLine) {
|
|
||||||
lastLine.textContent += evt.text;
|
|
||||||
}
|
|
||||||
while (cwOutputEl.children.length > CW_MAX_LINES) {
|
|
||||||
cwOutputEl.removeChild(cwOutputEl.firstChild);
|
|
||||||
}
|
|
||||||
cwOutputEl.scrollTop = cwOutputEl.scrollHeight;
|
|
||||||
}
|
|
||||||
if (evt.text) {
|
|
||||||
const now = Date.now();
|
|
||||||
if (evt.text === "\n") {
|
|
||||||
cwBarFlushCurrentLine();
|
|
||||||
} else {
|
|
||||||
if (!cwBarCurrentLine || now - cwBarCurrentLine.lastMs > CW_BAR_LINE_GAP_MS) {
|
|
||||||
cwBarFlushCurrentLine();
|
|
||||||
const ts = new Date(now).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
|
||||||
cwBarCurrentLine = { tsMs: now, ts, text: "", wpm: null, tone_hz: null, lastMs: now };
|
|
||||||
}
|
|
||||||
cwBarCurrentLine.text += evt.text;
|
|
||||||
cwBarCurrentLine.lastMs = now;
|
|
||||||
if (Number.isFinite(Number(evt.wpm))) cwBarCurrentLine.wpm = clampCwWpm(evt.wpm);
|
|
||||||
if (Number.isFinite(Number(evt.tone_hz))) cwBarCurrentLine.tone_hz = Math.round(Number(evt.tone_hz));
|
|
||||||
}
|
|
||||||
updateCwBar();
|
updateCwBar();
|
||||||
}
|
|
||||||
if (cwSignalIndicator) {
|
|
||||||
cwSignalIndicator.className = evt.signal_on ? "cw-signal-on" : "cw-signal-off";
|
|
||||||
}
|
|
||||||
if (!cwAutoInput || cwAutoInput.checked) {
|
|
||||||
if (cwWpmInput && Number.isFinite(Number(evt.wpm))) {
|
|
||||||
cwWpmInput.value = clampCwWpm(evt.wpm);
|
|
||||||
}
|
|
||||||
if (cwToneInput && Number.isFinite(Number(evt.tone_hz))) {
|
|
||||||
cwToneInput.value = toneClampForRange(evt.tone_hz, currentCwToneRange());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (cwTonePickerRaf != null) return;
|
|
||||||
cwTonePickerRaf = requestAnimationFrame(() => {
|
|
||||||
cwTonePickerRaf = null;
|
|
||||||
drawCwTonePicker();
|
drawCwTonePicker();
|
||||||
|
};
|
||||||
|
document.getElementById("settings-clear-cw-history")?.addEventListener("click", () => {
|
||||||
|
void (async () => {
|
||||||
|
if (!await cwWindow.trxUi.confirm({ title: "Clear CW history?", message: "All stored CW decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||||
|
try {
|
||||||
|
await cwWindow.postPath?.("/clear_cw_decode");
|
||||||
|
cwWindow.resetCwHistoryView?.();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("CW history clear failed", error);
|
||||||
|
}
|
||||||
|
})();
|
||||||
});
|
});
|
||||||
};
|
cwWindow.onServerCw = function(evt) {
|
||||||
window.restoreCwHistory = function(events) {
|
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
|
||||||
if (!Array.isArray(events) || events.length === 0) return;
|
if (evt.text && cwOutputEl) {
|
||||||
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
|
const now = Date.now();
|
||||||
for (const evt of events) {
|
if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 1e4 || evt.text === "\n") {
|
||||||
window.onServerCw(evt);
|
const line = document.createElement("div");
|
||||||
}
|
line.className = "cw-line";
|
||||||
};
|
cwOutputEl.appendChild(line);
|
||||||
window.refreshCwTonePicker = function refreshCwTonePicker() {
|
}
|
||||||
|
cwLastAppendTime = now;
|
||||||
|
const lastLine = cwOutputEl.lastElementChild;
|
||||||
|
if (lastLine) {
|
||||||
|
lastLine.textContent += evt.text;
|
||||||
|
}
|
||||||
|
while (cwOutputEl.children.length > CW_MAX_LINES) {
|
||||||
|
const firstChild = cwOutputEl.firstChild;
|
||||||
|
if (!firstChild) break;
|
||||||
|
cwOutputEl.removeChild(firstChild);
|
||||||
|
}
|
||||||
|
cwOutputEl.scrollTop = cwOutputEl.scrollHeight;
|
||||||
|
}
|
||||||
|
if (evt.text) {
|
||||||
|
const now = Date.now();
|
||||||
|
if (evt.text === "\n") {
|
||||||
|
cwBarFlushCurrentLine();
|
||||||
|
} else {
|
||||||
|
if (!cwBarCurrentLine || now - cwBarCurrentLine.lastMs > CW_BAR_LINE_GAP_MS) {
|
||||||
|
cwBarFlushCurrentLine();
|
||||||
|
const ts = new Date(now).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
|
cwBarCurrentLine = { tsMs: now, ts, text: "", wpm: null, tone_hz: null, lastMs: now };
|
||||||
|
}
|
||||||
|
cwBarCurrentLine.text += evt.text;
|
||||||
|
cwBarCurrentLine.lastMs = now;
|
||||||
|
if (Number.isFinite(Number(evt.wpm))) cwBarCurrentLine.wpm = clampCwWpm(evt.wpm);
|
||||||
|
if (Number.isFinite(Number(evt.tone_hz))) cwBarCurrentLine.tone_hz = Math.round(Number(evt.tone_hz));
|
||||||
|
}
|
||||||
|
updateCwBar();
|
||||||
|
}
|
||||||
|
if (cwSignalIndicator) {
|
||||||
|
cwSignalIndicator.className = evt.signal_on ? "cw-signal-on" : "cw-signal-off";
|
||||||
|
}
|
||||||
|
if (!cwAutoInput || cwAutoInput.checked) {
|
||||||
|
if (cwWpmInput && Number.isFinite(Number(evt.wpm))) {
|
||||||
|
cwWpmInput.value = String(clampCwWpm(evt.wpm));
|
||||||
|
}
|
||||||
|
if (cwToneInput && Number.isFinite(Number(evt.tone_hz))) {
|
||||||
|
cwToneInput.value = String(toneClampForRange(evt.tone_hz, currentCwToneRange()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cwTonePickerRaf != null) return;
|
||||||
|
cwTonePickerRaf = requestAnimationFrame(() => {
|
||||||
|
cwTonePickerRaf = null;
|
||||||
|
drawCwTonePicker();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
cwWindow.restoreCwHistory = function(events) {
|
||||||
|
if (!Array.isArray(events) || events.length === 0) return;
|
||||||
|
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
|
||||||
|
for (const evt of events) {
|
||||||
|
cwWindow.onServerCw?.(evt);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
cwWindow.refreshCwTonePicker = function refreshCwTonePicker() {
|
||||||
|
ensureCwToneCanvasResolution();
|
||||||
|
drawCwTonePicker();
|
||||||
|
};
|
||||||
|
window.addEventListener("resize", () => {
|
||||||
|
if (ensureCwToneCanvasResolution()) drawCwTonePicker();
|
||||||
|
});
|
||||||
|
applyCwAutoUi(!!cwAutoInput?.checked);
|
||||||
|
updateCwBar();
|
||||||
ensureCwToneCanvasResolution();
|
ensureCwToneCanvasResolution();
|
||||||
drawCwTonePicker();
|
drawCwTonePicker();
|
||||||
};
|
})();
|
||||||
window.addEventListener("resize", () => {
|
|
||||||
if (ensureCwToneCanvasResolution()) drawCwTonePicker();
|
|
||||||
});
|
|
||||||
applyCwAutoUi(!!cwAutoInput?.checked);
|
|
||||||
updateCwBar();
|
|
||||||
ensureCwToneCanvasResolution();
|
|
||||||
drawCwTonePicker();
|
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ await build({
|
|||||||
aprs: path.join(sourceDir, "plugins", "aprs.js"),
|
aprs: path.join(sourceDir, "plugins", "aprs.js"),
|
||||||
"background-decode": path.join(sourceDir, "plugins", "background-decode.js"),
|
"background-decode": path.join(sourceDir, "plugins", "background-decode.js"),
|
||||||
bookmarks: path.join(sourceDir, "plugins", "bookmarks.js"),
|
bookmarks: path.join(sourceDir, "plugins", "bookmarks.js"),
|
||||||
cw: path.join(sourceDir, "plugins", "cw.js"),
|
|
||||||
ft8: path.join(sourceDir, "plugins", "ft8.js"),
|
ft8: path.join(sourceDir, "plugins", "ft8.js"),
|
||||||
"hf-aprs": path.join(sourceDir, "plugins", "hf-aprs.js"),
|
"hf-aprs": path.join(sourceDir, "plugins", "hf-aprs.js"),
|
||||||
sat: path.join(sourceDir, "plugins", "sat.js"),
|
sat: path.join(sourceDir, "plugins", "sat.js"),
|
||||||
@@ -50,6 +49,7 @@ await build({
|
|||||||
ft2: path.join(sourceDir, "plugins", "ft2.ts"),
|
ft2: path.join(sourceDir, "plugins", "ft2.ts"),
|
||||||
ft4: path.join(sourceDir, "plugins", "ft4.ts"),
|
ft4: path.join(sourceDir, "plugins", "ft4.ts"),
|
||||||
wspr: path.join(sourceDir, "plugins", "wspr.ts"),
|
wspr: path.join(sourceDir, "plugins", "wspr.ts"),
|
||||||
|
cw: path.join(sourceDir, "plugins", "cw.ts"),
|
||||||
},
|
},
|
||||||
outdir: outputDir,
|
outdir: outputDir,
|
||||||
bundle: true,
|
bundle: true,
|
||||||
|
|||||||
+152
-101
@@ -2,16 +2,57 @@
|
|||||||
//
|
//
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
export {};
|
||||||
|
|
||||||
|
type Rgba = [number, number, number, number];
|
||||||
|
interface CwRenderer {
|
||||||
|
ready: boolean;
|
||||||
|
ensureSize(width: number, height: number, dpr: number): boolean;
|
||||||
|
clear(color: Rgba): void;
|
||||||
|
fillRect(x: number, y: number, width: number, height: number, color: Rgba): void;
|
||||||
|
drawSegments(points: number[], color: Rgba, width: number): void;
|
||||||
|
drawFilledArea(points: number[], baseline: number, color: Rgba): void;
|
||||||
|
drawPolyline(points: number[], color: Rgba, width: number): void;
|
||||||
|
drawPoints(points: number[], size: number, color: Rgba): void;
|
||||||
|
}
|
||||||
|
interface CwSpectrum { bins: number[]; sample_rate: number; center_hz: number }
|
||||||
|
interface CwEvent { text?: string; wpm?: number; tone_hz?: number; signal_on?: boolean }
|
||||||
|
interface CwLine { tsMs: number; ts: string; text: string; wpm: number | null; tone_hz: number | null; lastMs: number }
|
||||||
|
interface CwToneRange {
|
||||||
|
tunedHz: number; bandwidthHz: number; toneMinHz: number; toneMaxHz: number;
|
||||||
|
toneSpanHz: number; lowerSideband: boolean; mode: string;
|
||||||
|
}
|
||||||
|
interface CwBridge {
|
||||||
|
createTrxWebGlRenderer?: (canvas: HTMLCanvasElement, options: WebGLContextAttributes) => CwRenderer;
|
||||||
|
trxParseCssColor?: (color: string) => Rgba;
|
||||||
|
lastFreqHz?: number;
|
||||||
|
currentBandwidthHz?: number;
|
||||||
|
lastSpectrumData?: CwSpectrum;
|
||||||
|
escapeMapHtml?: (input: string) => string;
|
||||||
|
postPath?: (path: string) => Promise<unknown>;
|
||||||
|
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
|
||||||
|
applyCwAutoUi?: (enabled: boolean) => void;
|
||||||
|
applyCwAutoUiFromServer?: (enabled: boolean) => void;
|
||||||
|
updateCwBar?: () => void;
|
||||||
|
clearCwBar?: () => void;
|
||||||
|
closeCwBar?: () => void;
|
||||||
|
resetCwHistoryView?: () => void;
|
||||||
|
onServerCw?: (event: CwEvent) => void;
|
||||||
|
restoreCwHistory?: (events: CwEvent[]) => void;
|
||||||
|
refreshCwTonePicker?: () => void;
|
||||||
|
}
|
||||||
|
const cwWindow = window as unknown as CwBridge;
|
||||||
|
|
||||||
// --- CW (Morse) Decoder Plugin (server-side decode) ---
|
// --- CW (Morse) Decoder Plugin (server-side decode) ---
|
||||||
const cwStatusEl = document.getElementById("cw-status");
|
const cwStatusEl = document.getElementById("cw-status");
|
||||||
const cwOutputEl = document.getElementById("cw-output");
|
const cwOutputEl = document.getElementById("cw-output");
|
||||||
const cwAutoInput = document.getElementById("cw-auto");
|
const cwAutoInput = document.getElementById("cw-auto") as HTMLInputElement | null;
|
||||||
const cwWpmInput = document.getElementById("cw-wpm");
|
const cwWpmInput = document.getElementById("cw-wpm") as HTMLInputElement | null;
|
||||||
const cwToneInput = document.getElementById("cw-tone");
|
const cwToneInput = document.getElementById("cw-tone") as HTMLInputElement | null;
|
||||||
const cwSignalIndicator = document.getElementById("cw-signal-indicator");
|
const cwSignalIndicator = document.getElementById("cw-signal-indicator");
|
||||||
const cwToneCanvas = document.getElementById("cw-tone-waterfall");
|
const cwToneCanvas = document.getElementById("cw-tone-waterfall") as HTMLCanvasElement | null;
|
||||||
const cwToneGl = typeof createTrxWebGlRenderer === "function"
|
const cwToneGl = cwToneCanvas && cwWindow.createTrxWebGlRenderer
|
||||||
? createTrxWebGlRenderer(cwToneCanvas, { alpha: true })
|
? cwWindow.createTrxWebGlRenderer(cwToneCanvas, { alpha: true })
|
||||||
: null;
|
: null;
|
||||||
const cwTonePickerEl = document.querySelector(".cw-tone-picker");
|
const cwTonePickerEl = document.querySelector(".cw-tone-picker");
|
||||||
const cwToneRangeEl = document.getElementById("cw-tone-range");
|
const cwToneRangeEl = document.getElementById("cw-tone-range");
|
||||||
@@ -24,17 +65,23 @@ const CW_WPM_MAX = 40;
|
|||||||
const CW_BAR_WINDOW_MS = 15 * 60 * 1000;
|
const CW_BAR_WINDOW_MS = 15 * 60 * 1000;
|
||||||
const CW_BAR_LINE_GAP_MS = 5000;
|
const CW_BAR_LINE_GAP_MS = 5000;
|
||||||
let cwLastAppendTime = 0;
|
let cwLastAppendTime = 0;
|
||||||
let cwTonePickerRaf = null;
|
let cwTonePickerRaf: number | null = null;
|
||||||
let cwBarHistory = []; // [{tsMs, ts, text, wpm, tone_hz}]
|
let cwBarHistory: CwLine[] = [];
|
||||||
let cwBarCurrentLine = null; // accumulates chars until gap/newline
|
let cwBarCurrentLine: CwLine | null = null;
|
||||||
let cwBarDismissedAtMs = 0;
|
let cwBarDismissedAtMs = 0;
|
||||||
// Tracks a user-initiated auto toggle that is in-flight (POST not yet
|
// Tracks a user-initiated auto toggle that is in-flight (POST not yet
|
||||||
// acknowledged). While set, server-state updates must not override the
|
// acknowledged). While set, server-state updates must not override the
|
||||||
// checkbox so that a concurrent SSE event carrying the *old* cw_auto value
|
// checkbox so that a concurrent SSE event carrying the *old* cw_auto value
|
||||||
// does not immediately undo the user's choice.
|
// does not immediately undo the user's choice.
|
||||||
let cwAutoLocalOverride = null;
|
let cwAutoLocalOverride: boolean | null = null;
|
||||||
|
|
||||||
function applyCwAutoUi(enabled) {
|
function escapeCwHtml(input: string): string {
|
||||||
|
return cwWindow.escapeMapHtml?.(input) ?? input
|
||||||
|
.replaceAll("&", "&").replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">").replaceAll('"', """);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCwAutoUi(enabled: boolean): void {
|
||||||
if (cwAutoInput) cwAutoInput.checked = enabled;
|
if (cwAutoInput) cwAutoInput.checked = enabled;
|
||||||
if (cwWpmInput) {
|
if (cwWpmInput) {
|
||||||
cwWpmInput.disabled = enabled;
|
cwWpmInput.disabled = enabled;
|
||||||
@@ -48,16 +95,16 @@ function applyCwAutoUi(enabled) {
|
|||||||
cwTonePickerEl.classList.toggle("is-auto", enabled);
|
cwTonePickerEl.classList.toggle("is-auto", enabled);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.applyCwAutoUi = applyCwAutoUi;
|
cwWindow.applyCwAutoUi = applyCwAutoUi;
|
||||||
|
|
||||||
// Called by app.js render() when a server-state snapshot arrives. Ignores
|
// Called by app.js render() when a server-state snapshot arrives. Ignores
|
||||||
// the update while cwAutoLocalOverride is set (user change still in-flight).
|
// the update while cwAutoLocalOverride is set (user change still in-flight).
|
||||||
window.applyCwAutoUiFromServer = function(enabled) {
|
cwWindow.applyCwAutoUiFromServer = function(enabled: boolean) {
|
||||||
if (cwAutoLocalOverride !== null) return;
|
if (cwAutoLocalOverride !== null) return;
|
||||||
applyCwAutoUi(enabled);
|
applyCwAutoUi(enabled);
|
||||||
};
|
};
|
||||||
|
|
||||||
function cwBarFlushCurrentLine() {
|
function cwBarFlushCurrentLine(): void {
|
||||||
if (cwBarCurrentLine && cwBarCurrentLine.text.trim()) {
|
if (cwBarCurrentLine && cwBarCurrentLine.text.trim()) {
|
||||||
cwBarHistory.unshift(cwBarCurrentLine);
|
cwBarHistory.unshift(cwBarCurrentLine);
|
||||||
if (cwBarHistory.length > 50) cwBarHistory.length = 50;
|
if (cwBarHistory.length > 50) cwBarHistory.length = 50;
|
||||||
@@ -65,15 +112,15 @@ function cwBarFlushCurrentLine() {
|
|||||||
cwBarCurrentLine = null;
|
cwBarCurrentLine = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateCwBar() {
|
function updateCwBar(): void {
|
||||||
if (!cwBarOverlay) return;
|
if (!cwBarOverlay) return;
|
||||||
const mode = (document.getElementById("mode")?.value || "").toUpperCase();
|
const mode = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase();
|
||||||
const isCw = mode === "CW" || mode === "CWR";
|
const isCw = mode === "CW" || mode === "CWR";
|
||||||
const cutoffMs = Date.now() - CW_BAR_WINDOW_MS;
|
const cutoffMs = Date.now() - CW_BAR_WINDOW_MS;
|
||||||
const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs);
|
const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs);
|
||||||
// Prepend the in-progress line so characters appear immediately
|
// Prepend the in-progress line so characters appear immediately
|
||||||
const liveLines = cwBarCurrentLine && cwBarCurrentLine.text ? [cwBarCurrentLine, ...recent] : recent;
|
const liveLines = cwBarCurrentLine && cwBarCurrentLine.text ? [cwBarCurrentLine, ...recent] : recent;
|
||||||
const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, Number(line.tsMs) || 0), 0);
|
const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, line.tsMs || 0), 0);
|
||||||
if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) {
|
if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) {
|
||||||
cwBarOverlay.style.display = "none";
|
cwBarOverlay.style.display = "none";
|
||||||
cwBarOverlay.innerHTML = "";
|
cwBarOverlay.innerHTML = "";
|
||||||
@@ -94,22 +141,22 @@ function updateCwBar() {
|
|||||||
for (const line of liveLines.slice(0, 8)) {
|
for (const line of liveLines.slice(0, 8)) {
|
||||||
const ts = line.ts ? `<span class="aprs-bar-time">${line.ts}</span>` : "";
|
const ts = line.ts ? `<span class="aprs-bar-time">${line.ts}</span>` : "";
|
||||||
const meta = [
|
const meta = [
|
||||||
line.wpm ? `${line.wpm} WPM` : null,
|
line.wpm ? `${String(line.wpm)} WPM` : null,
|
||||||
line.tone_hz ? `${line.tone_hz} Hz` : null,
|
line.tone_hz ? `${String(line.tone_hz)} Hz` : null,
|
||||||
].filter(Boolean).join(" · ");
|
].filter(Boolean).join(" · ");
|
||||||
html += `<div class="aprs-bar-frame">` +
|
html += `<div class="aprs-bar-frame">` +
|
||||||
`<div class="aprs-bar-frame-main">${ts}${escapeMapHtml(line.text)}` +
|
`<div class="aprs-bar-frame-main">${ts}${escapeCwHtml(line.text)}` +
|
||||||
(meta ? ` <span class="aprs-bar-time">${escapeMapHtml(meta)}</span>` : "") +
|
(meta ? ` <span class="aprs-bar-time">${escapeCwHtml(meta)}</span>` : "") +
|
||||||
`</div></div>`;
|
`</div></div>`;
|
||||||
}
|
}
|
||||||
cwBarOverlay.innerHTML = html;
|
cwBarOverlay.innerHTML = html;
|
||||||
cwBarOverlay.style.display = "flex";
|
cwBarOverlay.style.display = "flex";
|
||||||
}
|
}
|
||||||
window.updateCwBar = updateCwBar;
|
cwWindow.updateCwBar = updateCwBar;
|
||||||
window.clearCwBar = function() {
|
cwWindow.clearCwBar = function() {
|
||||||
window.resetCwHistoryView();
|
cwWindow.resetCwHistoryView?.();
|
||||||
};
|
};
|
||||||
window.closeCwBar = function() {
|
cwWindow.closeCwBar = function() {
|
||||||
cwBarDismissedAtMs = Date.now();
|
cwBarDismissedAtMs = Date.now();
|
||||||
if (cwBarOverlay) {
|
if (cwBarOverlay) {
|
||||||
cwBarOverlay.style.display = "none";
|
cwBarOverlay.style.display = "none";
|
||||||
@@ -117,34 +164,31 @@ window.closeCwBar = function() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
function clampCwWpm(wpm) {
|
function clampCwWpm(wpm: unknown): number {
|
||||||
const numeric = Number(wpm);
|
const numeric = Number(wpm);
|
||||||
if (!Number.isFinite(numeric)) return 15;
|
if (!Number.isFinite(numeric)) return 15;
|
||||||
return Math.round(Math.max(CW_WPM_MIN, Math.min(CW_WPM_MAX, numeric)));
|
return Math.round(Math.max(CW_WPM_MIN, Math.min(CW_WPM_MAX, numeric)));
|
||||||
}
|
}
|
||||||
|
|
||||||
function clampCwTone(tone) {
|
function clampCwTone(tone: unknown): number {
|
||||||
const numeric = Number(tone);
|
const numeric = Number(tone);
|
||||||
if (!Number.isFinite(numeric)) return 700;
|
if (!Number.isFinite(numeric)) return 700;
|
||||||
return Math.round(Math.max(CW_TONE_MIN_HZ, Math.min(CW_TONE_MAX_HZ, numeric)));
|
return Math.round(Math.max(CW_TONE_MIN_HZ, Math.min(CW_TONE_MAX_HZ, numeric)));
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentCwToneRange() {
|
function currentCwToneRange(): CwToneRange | null {
|
||||||
const tunedHz = Number.isFinite(window.lastFreqHz) ? Number(window.lastFreqHz) : NaN;
|
const tunedHz = Number.isFinite(cwWindow.lastFreqHz) ? Number(cwWindow.lastFreqHz) : NaN;
|
||||||
const bandwidthHz = Number.isFinite(window.currentBandwidthHz) ? Number(window.currentBandwidthHz) : NaN;
|
const bandwidthHz = Number.isFinite(cwWindow.currentBandwidthHz) ? Number(cwWindow.currentBandwidthHz) : NaN;
|
||||||
if (!Number.isFinite(tunedHz) || !Number.isFinite(bandwidthHz) || bandwidthHz <= 0) {
|
if (!Number.isFinite(tunedHz) || !Number.isFinite(bandwidthHz) || bandwidthHz <= 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const mode = String(document.getElementById("mode")?.value || "").toUpperCase();
|
const mode = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase();
|
||||||
const lowerSideband = mode === "CWR";
|
const lowerSideband = mode === "CWR";
|
||||||
const upperSideband = mode === "CW";
|
const upperSideband = mode === "CW";
|
||||||
if (!lowerSideband && !upperSideband) return null;
|
if (!lowerSideband && !upperSideband) return null;
|
||||||
|
|
||||||
const toneMinHz = CW_TONE_MIN_HZ;
|
const toneMinHz = CW_TONE_MIN_HZ;
|
||||||
const toneMaxHz = CW_TONE_MAX_HZ;
|
const toneMaxHz = CW_TONE_MAX_HZ;
|
||||||
if (toneMaxHz < toneMinHz) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
tunedHz,
|
tunedHz,
|
||||||
bandwidthHz,
|
bandwidthHz,
|
||||||
@@ -156,20 +200,20 @@ function currentCwToneRange() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function cwToneToRfHz(range, toneHz) {
|
function cwToneToRfHz(range: CwToneRange | null, toneHz: number): number {
|
||||||
if (!range) return NaN;
|
if (!range) return NaN;
|
||||||
return range.lowerSideband
|
return range.lowerSideband
|
||||||
? range.tunedHz - toneHz
|
? range.tunedHz - toneHz
|
||||||
: range.tunedHz + toneHz;
|
: range.tunedHz + toneHz;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toneClampForRange(tone, range) {
|
function toneClampForRange(tone: unknown, range: CwToneRange | null): number {
|
||||||
const clamped = clampCwTone(tone);
|
const clamped = clampCwTone(tone);
|
||||||
if (!range) return clamped;
|
if (!range) return clamped;
|
||||||
return Math.max(range.toneMinHz, Math.min(range.toneMaxHz, clamped));
|
return Math.max(range.toneMinHz, Math.min(range.toneMaxHz, clamped));
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureCwToneCanvasResolution() {
|
function ensureCwToneCanvasResolution(): boolean {
|
||||||
if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return false;
|
if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return false;
|
||||||
const rect = cwToneCanvas.getBoundingClientRect();
|
const rect = cwToneCanvas.getBoundingClientRect();
|
||||||
const cssWidth = Math.round(rect.width);
|
const cssWidth = Math.round(rect.width);
|
||||||
@@ -181,7 +225,7 @@ function ensureCwToneCanvasResolution() {
|
|||||||
return cwToneGl.ensureSize(cssWidth, cssHeight, dpr);
|
return cwToneGl.ensureSize(cssWidth, cssHeight, dpr);
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawCwTonePicker() {
|
function drawCwTonePicker(): void {
|
||||||
if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return;
|
if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return;
|
||||||
ensureCwToneCanvasResolution();
|
ensureCwToneCanvasResolution();
|
||||||
if (cwToneCanvas.width < 8 || cwToneCanvas.height < 8) return;
|
if (cwToneCanvas.width < 8 || cwToneCanvas.height < 8) return;
|
||||||
@@ -190,12 +234,12 @@ function drawCwTonePicker() {
|
|||||||
cwToneGl.clear([0, 0, 0, 0]);
|
cwToneGl.clear([0, 0, 0, 0]);
|
||||||
|
|
||||||
const range = currentCwToneRange();
|
const range = currentCwToneRange();
|
||||||
if (!window.lastSpectrumData || !Array.isArray(window.lastSpectrumData.bins) || !window.lastSpectrumData.bins.length || !range) {
|
if (!cwWindow.lastSpectrumData || !Array.isArray(cwWindow.lastSpectrumData.bins) || !cwWindow.lastSpectrumData.bins.length || !range) {
|
||||||
if (cwToneRangeEl) {
|
if (cwToneRangeEl) {
|
||||||
const mode = String(document.getElementById("mode")?.value || "").toUpperCase();
|
const mode = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase();
|
||||||
if (mode !== "CW" && mode !== "CWR") {
|
if (mode !== "CW" && mode !== "CWR") {
|
||||||
cwToneRangeEl.textContent = "CW/CWR mode required";
|
cwToneRangeEl.textContent = "CW/CWR mode required";
|
||||||
} else if (!window.lastSpectrumData || !Array.isArray(window.lastSpectrumData.bins) || !window.lastSpectrumData.bins.length) {
|
} else if (!cwWindow.lastSpectrumData || !Array.isArray(cwWindow.lastSpectrumData.bins) || !cwWindow.lastSpectrumData.bins.length) {
|
||||||
cwToneRangeEl.textContent = "Waiting for spectrum";
|
cwToneRangeEl.textContent = "Waiting for spectrum";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -205,15 +249,15 @@ function drawCwTonePicker() {
|
|||||||
|
|
||||||
if (cwToneRangeEl) {
|
if (cwToneRangeEl) {
|
||||||
const side = range.lowerSideband ? "Lower side" : "Upper side";
|
const side = range.lowerSideband ? "Lower side" : "Upper side";
|
||||||
cwToneRangeEl.textContent = `Audio ${range.toneMinHz}-${range.toneMaxHz} Hz · ${side}`;
|
cwToneRangeEl.textContent = `Audio ${String(range.toneMinHz)}-${String(range.toneMaxHz)} Hz · ${side}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bins = window.lastSpectrumData.bins;
|
const bins = cwWindow.lastSpectrumData.bins;
|
||||||
const sampleRate = Number(window.lastSpectrumData.sample_rate);
|
const sampleRate = cwWindow.lastSpectrumData.sample_rate;
|
||||||
const centerHz = Number(window.lastSpectrumData.center_hz);
|
const centerHz = cwWindow.lastSpectrumData.center_hz;
|
||||||
const maxIdx = Math.max(1, bins.length - 1);
|
const maxIdx = Math.max(1, bins.length - 1);
|
||||||
const fullLoHz = centerHz - sampleRate / 2;
|
const fullLoHz = centerHz - sampleRate / 2;
|
||||||
const tones = new Array(width).fill(-140);
|
const tones: number[] = new Array<number>(width).fill(-140);
|
||||||
for (let x = 0; x < width; x += 1) {
|
for (let x = 0; x < width; x += 1) {
|
||||||
const frac = width <= 1 ? 0 : x / (width - 1);
|
const frac = width <= 1 ? 0 : x / (width - 1);
|
||||||
const toneHz = range.toneMinHz + frac * range.toneSpanHz;
|
const toneHz = range.toneMinHz + frac * range.toneSpanHz;
|
||||||
@@ -223,17 +267,17 @@ function drawCwTonePicker() {
|
|||||||
tones[x] = power;
|
tones[x] = power;
|
||||||
}
|
}
|
||||||
|
|
||||||
const smoothed = new Array(width).fill(-140);
|
const smoothed: number[] = new Array<number>(width).fill(-140);
|
||||||
const smoothRadius = Math.max(1, Math.round(width / 180));
|
const smoothRadius = Math.max(1, Math.round(width / 180));
|
||||||
for (let x = 0; x < width; x += 1) {
|
for (let x = 0; x < width; x += 1) {
|
||||||
let sum = 0;
|
let sum = 0;
|
||||||
let count = 0;
|
let count = 0;
|
||||||
for (let i = x - smoothRadius; i <= x + smoothRadius; i += 1) {
|
for (let i = x - smoothRadius; i <= x + smoothRadius; i += 1) {
|
||||||
if (i < 0 || i >= width) continue;
|
if (i < 0 || i >= width) continue;
|
||||||
sum += tones[i];
|
sum += tones[i] ?? -140;
|
||||||
count += 1;
|
count += 1;
|
||||||
}
|
}
|
||||||
smoothed[x] = count > 0 ? sum / count : tones[x];
|
smoothed[x] = count > 0 ? sum / count : tones[x] ?? -140;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sorted = smoothed.slice().sort((a, b) => a - b);
|
const sorted = smoothed.slice().sort((a, b) => a - b);
|
||||||
@@ -242,23 +286,23 @@ function drawCwTonePicker() {
|
|||||||
const floorDb = Math.min(q20 - 2, q95 - 10);
|
const floorDb = Math.min(q20 - 2, q95 - 10);
|
||||||
const ceilDb = Math.max(floorDb + 18, q95 + 2);
|
const ceilDb = Math.max(floorDb + 18, q95 + 2);
|
||||||
const dbSpan = Math.max(1, ceilDb - floorDb);
|
const dbSpan = Math.max(1, ceilDb - floorDb);
|
||||||
const yForDb = (db) => {
|
const yForDb = (db: number): number => {
|
||||||
const n = Math.max(0, Math.min(1, (db - floorDb) / dbSpan));
|
const n = Math.max(0, Math.min(1, (db - floorDb) / dbSpan));
|
||||||
return Math.round((1 - n) * (height - 1));
|
return Math.round((1 - n) * (height - 1));
|
||||||
};
|
};
|
||||||
|
|
||||||
const rootStyle = getComputedStyle(document.documentElement);
|
const rootStyle = getComputedStyle(document.documentElement);
|
||||||
const accent = (rootStyle.getPropertyValue("--accent-green") || "").trim() || "#00d17f";
|
const accent = (rootStyle.getPropertyValue("--accent-green") || "").trim() || "#00d17f";
|
||||||
const parseColor = typeof window.trxParseCssColor === "function"
|
const parseColor = typeof cwWindow.trxParseCssColor === "function"
|
||||||
? window.trxParseCssColor
|
? cwWindow.trxParseCssColor
|
||||||
: null;
|
: null;
|
||||||
const accentRgba = parseColor ? parseColor(accent) : [0, 0.82, 0.5, 1];
|
const accentRgba: Rgba = parseColor ? parseColor(accent) : [0, 0.82, 0.5, 1];
|
||||||
const axisColor = [230 / 255, 235 / 255, 245 / 255, 0.15];
|
const axisColor: Rgba = [230 / 255, 235 / 255, 245 / 255, 0.15];
|
||||||
|
|
||||||
cwToneGl.fillRect(0, 0, width, height, [7 / 255, 12 / 255, 18 / 255, 0.94]);
|
cwToneGl.fillRect(0, 0, width, height, [7 / 255, 12 / 255, 18 / 255, 0.94]);
|
||||||
|
|
||||||
const hGridCount = 4;
|
const hGridCount = 4;
|
||||||
const gridSegments = [];
|
const gridSegments: number[] = [];
|
||||||
for (let i = 1; i <= hGridCount; i += 1) {
|
for (let i = 1; i <= hGridCount; i += 1) {
|
||||||
const y = Math.round((i / (hGridCount + 1)) * (height - 1));
|
const y = Math.round((i / (hGridCount + 1)) * (height - 1));
|
||||||
gridSegments.push(0, y, width, y);
|
gridSegments.push(0, y, width, y);
|
||||||
@@ -267,7 +311,7 @@ function drawCwTonePicker() {
|
|||||||
|
|
||||||
const toneStep = range.toneSpanHz <= 500 ? 50 : range.toneSpanHz <= 1000 ? 100 : 200;
|
const toneStep = range.toneSpanHz <= 500 ? 50 : range.toneSpanHz <= 1000 ? 100 : 200;
|
||||||
const firstTick = Math.ceil(range.toneMinHz / toneStep) * toneStep;
|
const firstTick = Math.ceil(range.toneMinHz / toneStep) * toneStep;
|
||||||
const tickSegments = [];
|
const tickSegments: number[] = [];
|
||||||
for (let tone = firstTick; tone <= range.toneMaxHz; tone += toneStep) {
|
for (let tone = firstTick; tone <= range.toneMaxHz; tone += toneStep) {
|
||||||
const frac = (tone - range.toneMinHz) / range.toneSpanHz;
|
const frac = (tone - range.toneMinHz) / range.toneSpanHz;
|
||||||
const x = Math.max(0, Math.min(width - 1, Math.round(frac * (width - 1))));
|
const x = Math.max(0, Math.min(width - 1, Math.round(frac * (width - 1))));
|
||||||
@@ -275,9 +319,9 @@ function drawCwTonePicker() {
|
|||||||
}
|
}
|
||||||
cwToneGl.drawSegments(tickSegments, axisColor, 1);
|
cwToneGl.drawSegments(tickSegments, axisColor, 1);
|
||||||
|
|
||||||
const linePoints = [];
|
const linePoints: number[] = [];
|
||||||
for (let x = 0; x < width; x += 1) {
|
for (let x = 0; x < width; x += 1) {
|
||||||
linePoints.push(x, yForDb(smoothed[x]));
|
linePoints.push(x, yForDb(smoothed[x] ?? -140));
|
||||||
}
|
}
|
||||||
cwToneGl.drawFilledArea(linePoints, height, [accentRgba[0], accentRgba[1], accentRgba[2], 0.24]);
|
cwToneGl.drawFilledArea(linePoints, height, [accentRgba[0], accentRgba[1], accentRgba[2], 0.24]);
|
||||||
cwToneGl.drawPolyline(linePoints, accentRgba, Math.max(1.2, (window.devicePixelRatio || 1) * 1.2));
|
cwToneGl.drawPolyline(linePoints, accentRgba, Math.max(1.2, (window.devicePixelRatio || 1) * 1.2));
|
||||||
@@ -285,7 +329,7 @@ function drawCwTonePicker() {
|
|||||||
const currentTone = toneClampForRange(cwToneInput ? cwToneInput.value : 700, range);
|
const currentTone = toneClampForRange(cwToneInput ? cwToneInput.value : 700, range);
|
||||||
const markerFrac = (currentTone - range.toneMinHz) / range.toneSpanHz;
|
const markerFrac = (currentTone - range.toneMinHz) / range.toneSpanHz;
|
||||||
const markerX = Math.max(0, Math.min(width - 1, Math.round(markerFrac * (width - 1))));
|
const markerX = Math.max(0, Math.min(width - 1, Math.round(markerFrac * (width - 1))));
|
||||||
const markerY = yForDb(smoothed[Math.max(0, Math.min(width - 1, markerX))]);
|
const markerY = yForDb(smoothed[Math.max(0, Math.min(width - 1, markerX))] ?? -140);
|
||||||
cwToneGl.drawSegments([markerX, 0, markerX, height], [1, 1, 1, 0.9], 1.5);
|
cwToneGl.drawSegments([markerX, 0, markerX, height], [1, 1, 1, 0.9], 1.5);
|
||||||
cwToneGl.drawPoints([markerX, markerY], Math.max(2, Math.round(height * 0.055)), [1, 1, 1, 0.9]);
|
cwToneGl.drawPoints([markerX, markerY], Math.max(2, Math.round(height * 0.055)), [1, 1, 1, 0.9]);
|
||||||
|
|
||||||
@@ -294,14 +338,14 @@ function drawCwTonePicker() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setCwTone(tone, { syncInput = true } = {}) {
|
async function setCwTone(tone: unknown, { syncInput = true }: { syncInput?: boolean } = {}): Promise<void> {
|
||||||
const range = currentCwToneRange();
|
const range = currentCwToneRange();
|
||||||
const clamped = toneClampForRange(tone, range);
|
const clamped = toneClampForRange(tone, range);
|
||||||
if (cwToneInput && syncInput) {
|
if (cwToneInput && syncInput) {
|
||||||
cwToneInput.value = clamped;
|
cwToneInput.value = String(clamped);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await postPath(`/set_cw_tone?tone_hz=${encodeURIComponent(clamped)}`);
|
await cwWindow.postPath?.(`/set_cw_tone?tone_hz=${encodeURIComponent(clamped)}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("CW tone set failed", e);
|
console.error("CW tone set failed", e);
|
||||||
}
|
}
|
||||||
@@ -309,40 +353,43 @@ async function setCwTone(tone, { syncInput = true } = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (cwAutoInput) {
|
if (cwAutoInput) {
|
||||||
cwAutoInput.addEventListener("change", async () => {
|
cwAutoInput.addEventListener("change", () => {
|
||||||
const enabled = cwAutoInput.checked;
|
void (async () => {
|
||||||
cwAutoLocalOverride = enabled;
|
const enabled = cwAutoInput.checked;
|
||||||
applyCwAutoUi(enabled);
|
cwAutoLocalOverride = enabled;
|
||||||
try {
|
applyCwAutoUi(enabled);
|
||||||
await postPath(`/set_cw_auto?enabled=${enabled ? "true" : "false"}`);
|
try {
|
||||||
drawCwTonePicker();
|
await cwWindow.postPath?.(`/set_cw_auto?enabled=${enabled ? "true" : "false"}`);
|
||||||
} catch (e) {
|
drawCwTonePicker();
|
||||||
console.error("CW auto toggle failed", e);
|
} catch (error: unknown) {
|
||||||
} finally {
|
console.error("CW auto toggle failed", error);
|
||||||
cwAutoLocalOverride = null;
|
} finally {
|
||||||
}
|
cwAutoLocalOverride = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cwWpmInput) {
|
if (cwWpmInput) {
|
||||||
cwWpmInput.addEventListener("change", async () => {
|
cwWpmInput.addEventListener("change", () => {
|
||||||
if (cwAutoInput && cwAutoInput.checked) return;
|
void (async () => {
|
||||||
const wpm = clampCwWpm(cwWpmInput.value);
|
if (cwAutoInput?.checked) return;
|
||||||
cwWpmInput.value = wpm;
|
const wpm = clampCwWpm(cwWpmInput.value);
|
||||||
try { await postPath(`/set_cw_wpm?wpm=${encodeURIComponent(wpm)}`); }
|
cwWpmInput.value = String(wpm);
|
||||||
catch (e) { console.error("CW WPM set failed", e); }
|
try { await cwWindow.postPath?.(`/set_cw_wpm?wpm=${encodeURIComponent(wpm)}`); }
|
||||||
|
catch (error: unknown) { console.error("CW WPM set failed", error); }
|
||||||
|
})();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cwToneInput) {
|
if (cwToneInput) {
|
||||||
cwToneInput.addEventListener("change", async () => {
|
cwToneInput.addEventListener("change", () => {
|
||||||
if (cwAutoInput?.checked) return;
|
if (!cwAutoInput?.checked) void setCwTone(cwToneInput.value);
|
||||||
await setCwTone(cwToneInput.value);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cwToneCanvas) {
|
if (cwToneCanvas) {
|
||||||
cwToneCanvas.addEventListener("click", async (event) => {
|
cwToneCanvas.addEventListener("click", (event) => {
|
||||||
if (cwAutoInput?.checked) return;
|
if (cwAutoInput?.checked) return;
|
||||||
const rect = cwToneCanvas.getBoundingClientRect();
|
const rect = cwToneCanvas.getBoundingClientRect();
|
||||||
if (rect.width <= 0) return;
|
if (rect.width <= 0) return;
|
||||||
@@ -350,11 +397,11 @@ if (cwToneCanvas) {
|
|||||||
if (!range) return;
|
if (!range) return;
|
||||||
const frac = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
|
const frac = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
|
||||||
const tone = range.toneMinHz + frac * range.toneSpanHz;
|
const tone = range.toneMinHz + frac * range.toneSpanHz;
|
||||||
await setCwTone(tone);
|
void setCwTone(tone);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
window.resetCwHistoryView = function() {
|
cwWindow.resetCwHistoryView = function() {
|
||||||
if (cwOutputEl) cwOutputEl.innerHTML = "";
|
if (cwOutputEl) cwOutputEl.innerHTML = "";
|
||||||
cwLastAppendTime = 0;
|
cwLastAppendTime = 0;
|
||||||
cwBarHistory = [];
|
cwBarHistory = [];
|
||||||
@@ -363,18 +410,20 @@ window.resetCwHistoryView = function() {
|
|||||||
drawCwTonePicker();
|
drawCwTonePicker();
|
||||||
};
|
};
|
||||||
|
|
||||||
document.getElementById("settings-clear-cw-history")?.addEventListener("click", async () => {
|
document.getElementById("settings-clear-cw-history")?.addEventListener("click", () => {
|
||||||
if (!await window.trxUi.confirm({ title: "Clear CW history?", message: "All stored CW decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
|
void (async () => {
|
||||||
try {
|
if (!await cwWindow.trxUi.confirm({ title: "Clear CW history?", message: "All stored CW decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||||
await postPath("/clear_cw_decode");
|
try {
|
||||||
window.resetCwHistoryView();
|
await cwWindow.postPath?.("/clear_cw_decode");
|
||||||
} catch (e) {
|
cwWindow.resetCwHistoryView?.();
|
||||||
console.error("CW history clear failed", e);
|
} catch (error: unknown) {
|
||||||
}
|
console.error("CW history clear failed", error);
|
||||||
|
}
|
||||||
|
})();
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Server-side CW decode handler ---
|
// --- Server-side CW decode handler ---
|
||||||
window.onServerCw = function(evt) {
|
cwWindow.onServerCw = function(evt: CwEvent) {
|
||||||
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
|
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
|
||||||
if (evt.text && cwOutputEl) {
|
if (evt.text && cwOutputEl) {
|
||||||
// Append decoded text to output
|
// Append decoded text to output
|
||||||
@@ -390,7 +439,9 @@ window.onServerCw = function(evt) {
|
|||||||
lastLine.textContent += evt.text;
|
lastLine.textContent += evt.text;
|
||||||
}
|
}
|
||||||
while (cwOutputEl.children.length > CW_MAX_LINES) {
|
while (cwOutputEl.children.length > CW_MAX_LINES) {
|
||||||
cwOutputEl.removeChild(cwOutputEl.firstChild);
|
const firstChild = cwOutputEl.firstChild;
|
||||||
|
if (!firstChild) break;
|
||||||
|
cwOutputEl.removeChild(firstChild);
|
||||||
}
|
}
|
||||||
cwOutputEl.scrollTop = cwOutputEl.scrollHeight;
|
cwOutputEl.scrollTop = cwOutputEl.scrollHeight;
|
||||||
}
|
}
|
||||||
@@ -417,10 +468,10 @@ window.onServerCw = function(evt) {
|
|||||||
}
|
}
|
||||||
if (!cwAutoInput || cwAutoInput.checked) {
|
if (!cwAutoInput || cwAutoInput.checked) {
|
||||||
if (cwWpmInput && Number.isFinite(Number(evt.wpm))) {
|
if (cwWpmInput && Number.isFinite(Number(evt.wpm))) {
|
||||||
cwWpmInput.value = clampCwWpm(evt.wpm);
|
cwWpmInput.value = String(clampCwWpm(evt.wpm));
|
||||||
}
|
}
|
||||||
if (cwToneInput && Number.isFinite(Number(evt.tone_hz))) {
|
if (cwToneInput && Number.isFinite(Number(evt.tone_hz))) {
|
||||||
cwToneInput.value = toneClampForRange(evt.tone_hz, currentCwToneRange());
|
cwToneInput.value = String(toneClampForRange(evt.tone_hz, currentCwToneRange()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (cwTonePickerRaf != null) return;
|
if (cwTonePickerRaf != null) return;
|
||||||
@@ -430,15 +481,15 @@ window.onServerCw = function(evt) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
window.restoreCwHistory = function(events) {
|
cwWindow.restoreCwHistory = function(events: CwEvent[]) {
|
||||||
if (!Array.isArray(events) || events.length === 0) return;
|
if (!Array.isArray(events) || events.length === 0) return;
|
||||||
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
|
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
|
||||||
for (const evt of events) {
|
for (const evt of events) {
|
||||||
window.onServerCw(evt);
|
cwWindow.onServerCw?.(evt);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.refreshCwTonePicker = function refreshCwTonePicker() {
|
cwWindow.refreshCwTonePicker = function refreshCwTonePicker() {
|
||||||
ensureCwToneCanvasResolution();
|
ensureCwToneCanvasResolution();
|
||||||
drawCwTonePicker();
|
drawCwTonePicker();
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import test from "node:test";
|
||||||
|
import vm from "node:vm";
|
||||||
|
|
||||||
|
class ElementFixture {
|
||||||
|
constructor() { this.children = []; this.textContent = ""; this.style = {}; this.classList = { toggle() {} }; }
|
||||||
|
appendChild(child) { this.children.push(child); return child; }
|
||||||
|
removeChild(child) { this.children.splice(this.children.indexOf(child), 1); }
|
||||||
|
get firstChild() { return this.children[0] ?? null; }
|
||||||
|
get lastElementChild() { return this.children.at(-1) ?? null; }
|
||||||
|
get scrollHeight() { return this.children.length; }
|
||||||
|
}
|
||||||
|
|
||||||
|
test("CW entry appends server-decoded text and registers lifecycle callbacks", async () => {
|
||||||
|
const output = new ElementFixture();
|
||||||
|
const status = new ElementFixture();
|
||||||
|
const elements = new Map([["cw-output", output], ["cw-status", status]]);
|
||||||
|
const window = { trxUi: { confirm: async () => true }, addEventListener() {} };
|
||||||
|
const context = vm.createContext({
|
||||||
|
window,
|
||||||
|
document: {
|
||||||
|
documentElement: {},
|
||||||
|
getElementById: (id) => elements.get(id) ?? null,
|
||||||
|
querySelector: () => null,
|
||||||
|
createElement: () => new ElementFixture(),
|
||||||
|
},
|
||||||
|
requestAnimationFrame(callback) { callback(); return 1; },
|
||||||
|
getComputedStyle: () => ({ getPropertyValue: () => "" }),
|
||||||
|
Date,
|
||||||
|
Number,
|
||||||
|
String,
|
||||||
|
Math,
|
||||||
|
console,
|
||||||
|
});
|
||||||
|
const source = await readFile(new URL("../../assets/web/generated/cw.js", import.meta.url), "utf8");
|
||||||
|
new vm.Script(source).runInContext(context);
|
||||||
|
|
||||||
|
window.onServerCw({ text: "CQ", wpm: 18, tone_hz: 700, signal_on: true });
|
||||||
|
assert.equal(status.textContent, "Receiving");
|
||||||
|
assert.equal(output.children.length, 1);
|
||||||
|
assert.equal(output.children[0].textContent, "CQ");
|
||||||
|
assert.equal(typeof window.resetCwHistoryView, "function");
|
||||||
|
assert.equal(typeof window.restoreCwHistory, "function");
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user