Sync docs to Wiki / wiki (push) Has been cancelled
Signed-off-by: Stan Grams <sjg@haxx.space>
452 lines
16 KiB
JavaScript
452 lines
16 KiB
JavaScript
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
//
|
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
|
|
// --- CW (Morse) Decoder Plugin (server-side decode) ---
|
|
const cwStatusEl = document.getElementById("cw-status");
|
|
const cwOutputEl = document.getElementById("cw-output");
|
|
const cwAutoInput = document.getElementById("cw-auto");
|
|
const cwWpmInput = document.getElementById("cw-wpm");
|
|
const cwToneInput = document.getElementById("cw-tone");
|
|
const cwSignalIndicator = document.getElementById("cw-signal-indicator");
|
|
const cwToneCanvas = document.getElementById("cw-tone-waterfall");
|
|
const cwToneGl = typeof createTrxWebGlRenderer === "function"
|
|
? createTrxWebGlRenderer(cwToneCanvas, { alpha: true })
|
|
: null;
|
|
const cwTonePickerEl = document.querySelector(".cw-tone-picker");
|
|
const cwToneRangeEl = document.getElementById("cw-tone-range");
|
|
const cwBarOverlay = document.getElementById("cw-bar-overlay");
|
|
const CW_MAX_LINES = 200;
|
|
const CW_TONE_MIN_HZ = 100;
|
|
const CW_TONE_MAX_HZ = 10_000;
|
|
const CW_WPM_MIN = 5;
|
|
const CW_WPM_MAX = 40;
|
|
const CW_BAR_WINDOW_MS = 15 * 60 * 1000;
|
|
const CW_BAR_LINE_GAP_MS = 5000;
|
|
let cwLastAppendTime = 0;
|
|
let cwTonePickerRaf = null;
|
|
let cwBarHistory = []; // [{tsMs, ts, text, wpm, tone_hz}]
|
|
let cwBarCurrentLine = null; // accumulates chars until gap/newline
|
|
let cwBarDismissedAtMs = 0;
|
|
// Tracks a user-initiated auto toggle that is in-flight (POST not yet
|
|
// acknowledged). While set, server-state updates must not override the
|
|
// checkbox so that a concurrent SSE event carrying the *old* cw_auto value
|
|
// does not immediately undo the user's choice.
|
|
let cwAutoLocalOverride = null;
|
|
|
|
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);
|
|
}
|
|
}
|
|
window.applyCwAutoUi = applyCwAutoUi;
|
|
|
|
// Called by app.js render() when a server-state snapshot arrives. Ignores
|
|
// the update while cwAutoLocalOverride is set (user change still in-flight).
|
|
window.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);
|
|
// Prepend the in-progress line so characters appear immediately
|
|
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 <= 1000 ? 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 (!confirm("Clear all CW decode history? This cannot be undone.")) return;
|
|
try {
|
|
await postPath("/clear_cw_decode");
|
|
window.resetCwHistoryView();
|
|
} catch (e) {
|
|
console.error("CW history clear failed", e);
|
|
}
|
|
});
|
|
|
|
// --- Server-side CW decode handler ---
|
|
window.onServerCw = function(evt) {
|
|
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
|
|
if (evt.text && cwOutputEl) {
|
|
// Append decoded text to output
|
|
const now = Date.now();
|
|
if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 10000 || 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;
|
|
}
|
|
// Bar history accumulation (regardless of pause state)
|
|
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 = 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();
|
|
});
|
|
};
|
|
|
|
window.restoreCwHistory = function(events) {
|
|
if (!Array.isArray(events) || events.length === 0) return;
|
|
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
|
|
for (const evt of events) {
|
|
window.onServerCw(evt);
|
|
}
|
|
};
|
|
|
|
window.refreshCwTonePicker = function refreshCwTonePicker() {
|
|
ensureCwToneCanvasResolution();
|
|
drawCwTonePicker();
|
|
};
|
|
window.addEventListener("resize", () => {
|
|
if (ensureCwToneCanvasResolution()) drawCwTonePicker();
|
|
});
|
|
applyCwAutoUi(!!cwAutoInput?.checked);
|
|
updateCwBar();
|
|
ensureCwToneCanvasResolution();
|
|
drawCwTonePicker();
|