[fix](trx-frontend): rework cw auto and tone picker behavior

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
2026-03-04 22:31:08 +01:00
parent d6ad873bfe
commit eac5e142db
4 changed files with 130 additions and 31 deletions
@@ -2302,10 +2302,20 @@ function render(update) {
if (cwToneEl && typeof update.cw_tone_hz === "number") { if (cwToneEl && typeof update.cw_tone_hz === "number") {
cwToneEl.value = update.cw_tone_hz; cwToneEl.value = update.cw_tone_hz;
} }
if (cwWpmEl && typeof update.cw_auto === "boolean") { if ((cwWpmEl || cwToneEl) && typeof update.cw_auto === "boolean") {
const disabled = update.cw_auto; const disabled = update.cw_auto;
cwWpmEl.disabled = disabled; if (typeof window.applyCwAutoUi === "function") {
cwWpmEl.readOnly = disabled; window.applyCwAutoUi(disabled);
} else {
if (cwWpmEl) {
cwWpmEl.disabled = disabled;
cwWpmEl.readOnly = disabled;
}
if (cwToneEl) {
cwToneEl.disabled = disabled;
cwToneEl.readOnly = disabled;
}
}
} }
let activeFreqColor = "var(--accent-green)"; let activeFreqColor = "var(--accent-green)";
if (update.status && update.status.vfo && Array.isArray(update.status.vfo.entries)) { if (update.status && update.status.vfo && Array.isArray(update.status.vfo.entries)) {
@@ -554,13 +554,13 @@
<div id="cw-signal-indicator" class="cw-signal-off"></div> <div id="cw-signal-indicator" class="cw-signal-off"></div>
</div> </div>
<div class="cw-config"> <div class="cw-config">
<label class="cw-auto-label">Auto WPM <input type="checkbox" id="cw-auto" checked /></label> <label class="cw-auto-label">Auto WPM + Tone <input type="checkbox" id="cw-auto" checked /></label>
<label>WPM <input type="number" id="cw-wpm" min="5" max="40" value="15" /></label> <label>WPM <input type="number" id="cw-wpm" min="5" max="40" value="15" /></label>
<label>Tone (Hz) <input type="number" id="cw-tone" min="300" max="1200" value="700" /></label> <label>Tone (Hz) <input type="number" id="cw-tone" min="300" max="1200" value="700" /></label>
</div> </div>
<div class="cw-tone-picker"> <div class="cw-tone-picker">
<div class="cw-tone-picker-head"> <div class="cw-tone-picker-head">
<span>CW Tone From Selected BW</span> <span>CW Tone Picker</span>
<small id="cw-tone-range">--</small> <small id="cw-tone-range">--</small>
</div> </div>
<canvas id="cw-tone-waterfall" width="320" height="56" aria-label="CW tone selector"></canvas> <canvas id="cw-tone-waterfall" width="320" height="56" aria-label="CW tone selector"></canvas>
@@ -6,10 +6,15 @@ const cwWpmInput = document.getElementById("cw-wpm");
const cwToneInput = document.getElementById("cw-tone"); const cwToneInput = document.getElementById("cw-tone");
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");
const cwTonePickerEl = document.querySelector(".cw-tone-picker");
const cwToneRangeEl = document.getElementById("cw-tone-range"); const cwToneRangeEl = document.getElementById("cw-tone-range");
const CW_MAX_LINES = 200; const CW_MAX_LINES = 200;
const CW_TONE_MIN_HZ = 300; const CW_TONE_MIN_HZ = 300;
const CW_TONE_MAX_HZ = 1200; const CW_TONE_MAX_HZ = 1200;
const CW_WPM_MIN = 5;
const CW_WPM_MAX = 40;
let cwLastAppendTime = 0;
let cwTonePickerRaf = null;
function applyCwAutoUi(enabled) { function applyCwAutoUi(enabled) {
if (cwAutoInput) cwAutoInput.checked = enabled; if (cwAutoInput) cwAutoInput.checked = enabled;
@@ -17,6 +22,20 @@ function applyCwAutoUi(enabled) {
cwWpmInput.disabled = enabled; cwWpmInput.disabled = enabled;
cwWpmInput.readOnly = enabled; cwWpmInput.readOnly = enabled;
} }
if (cwToneInput) {
cwToneInput.disabled = enabled;
cwToneInput.readOnly = enabled;
}
if (cwTonePickerEl) {
cwTonePickerEl.classList.toggle("is-auto", enabled);
}
}
window.applyCwAutoUi = applyCwAutoUi;
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) { function clampCwTone(tone) {
@@ -26,31 +45,56 @@ function clampCwTone(tone) {
} }
function currentCwToneRange() { function currentCwToneRange() {
const centerHz = Number.isFinite(window.lastFreqHz) ? Number(window.lastFreqHz) : Number.NaN; const centerHz = Number.isFinite(window.lastFreqHz) ? Number(window.lastFreqHz) : NaN;
const bandwidthHz = Number.isFinite(window.currentBandwidthHz) ? Number(window.currentBandwidthHz) : Number.NaN; const bandwidthHz = Number.isFinite(window.currentBandwidthHz) ? Number(window.currentBandwidthHz) : NaN;
if (!Number.isFinite(centerHz) || !Number.isFinite(bandwidthHz) || bandwidthHz <= 0) { if (!Number.isFinite(centerHz) || !Number.isFinite(bandwidthHz) || bandwidthHz <= 0) {
return null; return null;
} }
const mode = String(document.getElementById("mode")?.value || "").toUpperCase(); const mode = String(document.getElementById("mode")?.value || "").toUpperCase();
const lowerSideband = mode === "CWR" || mode === "LSB"; const lowerSideband = mode === "CWR";
const halfBwHz = bandwidthHz / 2; const upperSideband = mode === "CW";
if (!lowerSideband && !upperSideband) return null;
const lowHz = lowerSideband ? centerHz - bandwidthHz : centerHz;
const highHz = lowerSideband ? centerHz : centerHz + bandwidthHz;
const toneMinHz = CW_TONE_MIN_HZ; const toneMinHz = CW_TONE_MIN_HZ;
const toneMaxHz = Math.max(toneMinHz, Math.min(CW_TONE_MAX_HZ, Math.round(halfBwHz))); const toneMaxHz = Math.min(CW_TONE_MAX_HZ, Math.round(bandwidthHz));
if (toneMaxHz < toneMinHz) { if (toneMaxHz < toneMinHz) {
return null; return null;
} }
return { return {
lowHz: centerHz - halfBwHz, lowHz,
highHz: centerHz + halfBwHz, highHz,
centerHz, centerHz,
bandwidthHz, bandwidthHz,
halfBwHz,
toneMinHz, toneMinHz,
toneMaxHz, toneMaxHz,
lowerSideband, lowerSideband,
mode,
}; };
} }
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) return false;
const rect = cwToneCanvas.getBoundingClientRect();
const cssWidth = Math.max(1, Math.round(rect.width));
const cssHeight = Math.max(1, Math.round(rect.height));
const dpr = window.devicePixelRatio || 1;
const nextWidth = Math.max(1, Math.round(cssWidth * dpr));
const nextHeight = Math.max(1, Math.round(cssHeight * dpr));
if (cwToneCanvas.width !== nextWidth || cwToneCanvas.height !== nextHeight) {
cwToneCanvas.width = nextWidth;
cwToneCanvas.height = nextHeight;
return true;
}
return false;
}
function drawCwTonePicker() { function drawCwTonePicker() {
if (!cwToneCanvas) return; if (!cwToneCanvas) return;
const ctx = cwToneCanvas.getContext("2d"); const ctx = cwToneCanvas.getContext("2d");
@@ -62,14 +106,22 @@ function drawCwTonePicker() {
const range = currentCwToneRange(); const range = currentCwToneRange();
if (!range || !window.lastSpectrumData || !Array.isArray(window.lastSpectrumData.bins) || !window.lastSpectrumData.bins.length) { if (!range || !window.lastSpectrumData || !Array.isArray(window.lastSpectrumData.bins) || !window.lastSpectrumData.bins.length) {
if (cwToneRangeEl) cwToneRangeEl.textContent = "Waiting for spectrum"; if (cwToneRangeEl) {
const mode = String(document.getElementById("mode")?.value || "").toUpperCase();
if (mode !== "CW" && mode !== "CWR") {
cwToneRangeEl.textContent = "CW/CWR mode required";
} else {
cwToneRangeEl.textContent = "Waiting for spectrum";
}
}
ctx.fillStyle = "rgba(130, 150, 165, 0.22)"; ctx.fillStyle = "rgba(130, 150, 165, 0.22)";
ctx.fillRect(0, 0, width, height); ctx.fillRect(0, 0, width, height);
return; return;
} }
if (cwToneRangeEl) { if (cwToneRangeEl) {
cwToneRangeEl.textContent = `${(range.bandwidthHz / 1000).toFixed(range.bandwidthHz >= 10_000 ? 0 : 1)} kHz span`; const side = range.lowerSideband ? "Lower side" : "Upper side";
cwToneRangeEl.textContent = `${side} · Tone ${range.toneMinHz}-${range.toneMaxHz} Hz`;
} }
const bins = window.lastSpectrumData.bins; const bins = window.lastSpectrumData.bins;
@@ -99,7 +151,7 @@ function drawCwTonePicker() {
ctx.fillRect(x, 0, 1, height); ctx.fillRect(x, 0, 1, height);
} }
const currentTone = clampCwTone(cwToneInput ? cwToneInput.value : 700); const currentTone = toneClampForRange(cwToneInput ? cwToneInput.value : 700, range);
const markerHz = range.lowerSideband const markerHz = range.lowerSideband
? range.centerHz - currentTone ? range.centerHz - currentTone
: range.centerHz + currentTone; : range.centerHz + currentTone;
@@ -108,14 +160,27 @@ function drawCwTonePicker() {
ctx.fillStyle = "rgba(255, 255, 255, 0.9)"; ctx.fillStyle = "rgba(255, 255, 255, 0.9)";
ctx.fillRect(markerX, 0, 2, height); ctx.fillRect(markerX, 0, 2, height);
const centerFrac = (range.centerHz - range.lowHz) / Math.max(1, (range.highHz - range.lowHz)); const lowLimitHz = range.lowerSideband
const centerX = Math.max(0, Math.min(width - 1, Math.round(centerFrac * (width - 1)))); ? range.centerHz - range.toneMaxHz
: range.centerHz + range.toneMinHz;
const highLimitHz = range.lowerSideband
? range.centerHz - range.toneMinHz
: range.centerHz + range.toneMaxHz;
const limitLowX = Math.max(0, Math.min(width - 1, Math.round(((lowLimitHz - range.lowHz) / Math.max(1, range.highHz - range.lowHz)) * (width - 1))));
const limitHighX = Math.max(0, Math.min(width - 1, Math.round(((highLimitHz - range.lowHz) / Math.max(1, range.highHz - range.lowHz)) * (width - 1))));
ctx.fillStyle = "rgba(255, 255, 255, 0.22)"; ctx.fillStyle = "rgba(255, 255, 255, 0.22)";
ctx.fillRect(centerX, 0, 1, height); ctx.fillRect(limitLowX, 0, 1, height);
ctx.fillRect(limitHighX, 0, 1, height);
if (cwAutoInput?.checked) {
ctx.fillStyle = "rgba(0, 0, 0, 0.22)";
ctx.fillRect(0, 0, width, height);
}
} }
async function setCwTone(tone, { syncInput = true } = {}) { async function setCwTone(tone, { syncInput = true } = {}) {
const clamped = clampCwTone(tone); const range = currentCwToneRange();
const clamped = toneClampForRange(tone, range);
if (cwToneInput && syncInput) { if (cwToneInput && syncInput) {
cwToneInput.value = clamped; cwToneInput.value = clamped;
} }
@@ -131,15 +196,19 @@ if (cwAutoInput) {
cwAutoInput.addEventListener("change", async () => { cwAutoInput.addEventListener("change", async () => {
const enabled = cwAutoInput.checked; const enabled = cwAutoInput.checked;
applyCwAutoUi(enabled); applyCwAutoUi(enabled);
try { await postPath(`/set_cw_auto?enabled=${enabled ? "true" : "false"}`); } try {
catch (e) { console.error("CW auto toggle failed", e); } await postPath(`/set_cw_auto?enabled=${enabled ? "true" : "false"}`);
drawCwTonePicker();
} catch (e) {
console.error("CW auto toggle failed", e);
}
}); });
} }
if (cwWpmInput) { if (cwWpmInput) {
cwWpmInput.addEventListener("change", async () => { cwWpmInput.addEventListener("change", async () => {
if (cwAutoInput && cwAutoInput.checked) return; if (cwAutoInput && cwAutoInput.checked) return;
const wpm = Math.max(5, Math.min(40, Number(cwWpmInput.value))); const wpm = clampCwWpm(cwWpmInput.value);
cwWpmInput.value = wpm; cwWpmInput.value = wpm;
try { await postPath(`/set_cw_wpm?wpm=${encodeURIComponent(wpm)}`); } try { await postPath(`/set_cw_wpm?wpm=${encodeURIComponent(wpm)}`); }
catch (e) { console.error("CW WPM set failed", e); } catch (e) { console.error("CW WPM set failed", e); }
@@ -148,12 +217,14 @@ if (cwWpmInput) {
if (cwToneInput) { if (cwToneInput) {
cwToneInput.addEventListener("change", async () => { cwToneInput.addEventListener("change", async () => {
if (cwAutoInput?.checked) return;
await setCwTone(cwToneInput.value); await setCwTone(cwToneInput.value);
}); });
} }
if (cwToneCanvas) { if (cwToneCanvas) {
cwToneCanvas.addEventListener("click", async (event) => { cwToneCanvas.addEventListener("click", async (event) => {
if (cwAutoInput?.checked) return;
const rect = cwToneCanvas.getBoundingClientRect(); const rect = cwToneCanvas.getBoundingClientRect();
if (rect.width <= 0) return; if (rect.width <= 0) return;
const range = currentCwToneRange(); const range = currentCwToneRange();
@@ -169,7 +240,7 @@ if (cwToneCanvas) {
} }
window.resetCwHistoryView = function() { window.resetCwHistoryView = function() {
cwOutputEl.innerHTML = ""; if (cwOutputEl) cwOutputEl.innerHTML = "";
cwLastAppendTime = 0; cwLastAppendTime = 0;
drawCwTonePicker(); drawCwTonePicker();
}; };
@@ -184,10 +255,9 @@ document.getElementById("cw-clear-btn").addEventListener("click", async () => {
}); });
// --- Server-side CW decode handler --- // --- Server-side CW decode handler ---
let cwLastAppendTime = 0;
window.onServerCw = function(evt) { window.onServerCw = function(evt) {
cwStatusEl.textContent = "Receiving"; if (cwStatusEl) cwStatusEl.textContent = "Receiving";
if (evt.text) { if (evt.text && cwOutputEl) {
// Append decoded text to output // Append decoded text to output
const now = Date.now(); const now = Date.now();
if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 10000 || evt.text === "\n") { if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 10000 || evt.text === "\n") {
@@ -205,12 +275,28 @@ window.onServerCw = function(evt) {
} }
cwOutputEl.scrollTop = cwOutputEl.scrollHeight; cwOutputEl.scrollTop = cwOutputEl.scrollHeight;
} }
cwSignalIndicator.className = evt.signal_on ? "cw-signal-on" : "cw-signal-off"; if (cwSignalIndicator) {
if (!cwAutoInput || cwAutoInput.checked) { cwSignalIndicator.className = evt.signal_on ? "cw-signal-on" : "cw-signal-off";
cwWpmInput.value = evt.wpm;
} }
drawCwTonePicker(); 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.refreshCwTonePicker = drawCwTonePicker; window.refreshCwTonePicker = drawCwTonePicker;
window.addEventListener("resize", () => {
if (ensureCwToneCanvasResolution()) drawCwTonePicker();
});
applyCwAutoUi(!!cwAutoInput?.checked);
ensureCwToneCanvasResolution();
drawCwTonePicker(); drawCwTonePicker();
@@ -1739,8 +1739,10 @@ small { color: var(--text-muted); }
.cw-config label { display: flex; flex-direction: column; gap: 0.2rem; color: var(--text-muted); font-size: 0.82rem; } .cw-config label { display: flex; flex-direction: column; gap: 0.2rem; color: var(--text-muted); font-size: 0.82rem; }
.cw-config input[type="number"] { width: 5rem; padding: 0.3rem 0.4rem; font-size: 0.9rem; border: 1px solid var(--border-light); border-radius: 6px; background: var(--input-bg); color: var(--text); } .cw-config input[type="number"] { width: 5rem; padding: 0.3rem 0.4rem; font-size: 0.9rem; border: 1px solid var(--border-light); border-radius: 6px; background: var(--input-bg); color: var(--text); }
.cw-tone-picker { margin-bottom: 0.75rem; border: 1px solid var(--border-light); border-radius: 8px; background: var(--input-bg); padding: 0.5rem 0.6rem; } .cw-tone-picker { margin-bottom: 0.75rem; border: 1px solid var(--border-light); border-radius: 8px; background: var(--input-bg); padding: 0.5rem 0.6rem; }
.cw-tone-picker.is-auto { opacity: 0.82; }
.cw-tone-picker-head { display: flex; align-items: baseline; justify-content: space-between; gap: 0.6rem; margin-bottom: 0.35rem; color: var(--text-muted); font-size: 0.78rem; } .cw-tone-picker-head { display: flex; align-items: baseline; justify-content: space-between; gap: 0.6rem; margin-bottom: 0.35rem; color: var(--text-muted); font-size: 0.78rem; }
#cw-tone-waterfall { width: 100%; height: 56px; display: block; border-radius: 6px; background: linear-gradient(180deg, rgba(8, 14, 18, 0.92), rgba(18, 28, 36, 0.98)); cursor: crosshair; } #cw-tone-waterfall { width: 100%; height: 56px; display: block; border-radius: 6px; background: linear-gradient(180deg, rgba(8, 14, 18, 0.92), rgba(18, 28, 36, 0.98)); cursor: crosshair; }
.cw-tone-picker.is-auto #cw-tone-waterfall { cursor: not-allowed; }
#cw-output { max-height: 360px; overflow-y: auto; border: 1px solid var(--border-light); border-radius: 6px; background: var(--input-bg); font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.85rem; padding: 0.4rem 0.5rem; min-height: 60px; white-space: pre-wrap; word-break: break-all; } #cw-output { max-height: 360px; overflow-y: auto; border: 1px solid var(--border-light); border-radius: 6px; background: var(--input-bg); font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.85rem; padding: 0.4rem 0.5rem; min-height: 60px; white-space: pre-wrap; word-break: break-all; }
.cw-line { line-height: 1.5; } .cw-line { line-height: 1.5; }
.cw-signal-on { width: 10px; height: 10px; border-radius: 50%; background: var(--accent-green); box-shadow: 0 0 6px var(--accent-green); flex-shrink: 0; } .cw-signal-on { width: 10px; height: 10px; border-radius: 50%; background: var(--accent-green); box-shadow: 0 0 6px var(--accent-green); flex-shrink: 0; }
@@ -1748,6 +1750,7 @@ small { color: var(--text-muted); }
.cw-config .cw-auto-label { display: inline-flex; align-items: center; gap: 0.35rem; font-size: 0.82rem; color: var(--text-muted); cursor: pointer; flex-direction: row; } .cw-config .cw-auto-label { display: inline-flex; align-items: center; gap: 0.35rem; font-size: 0.82rem; color: var(--text-muted); cursor: pointer; flex-direction: row; }
.cw-auto-label input[type="checkbox"] { margin: 0; cursor: pointer; } .cw-auto-label input[type="checkbox"] { margin: 0; cursor: pointer; }
.cw-config input[type="number"][readonly] { opacity: 0.6; } .cw-config input[type="number"][readonly] { opacity: 0.6; }
.cw-config input[type="number"]:disabled { opacity: 0.58; }
button:focus-visible, input:focus-visible, select:focus-visible { button:focus-visible, input:focus-visible, select:focus-visible {
outline: 2px solid var(--accent-green); outline: 2px solid var(--accent-green);