diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js
index 36c5c40..350f4d9 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js
+++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js
@@ -1197,6 +1197,9 @@ function applyLocalTunedFrequency(hz, forceDisplay = false) {
if (window.updateFt8RfDisplay) {
window.updateFt8RfDisplay();
}
+ if (window.refreshCwTonePicker) {
+ window.refreshCwTonePicker();
+ }
if (lastSpectrumData) {
scheduleSpectrumDraw();
}
@@ -2083,6 +2086,9 @@ function render(update) {
if (update.filter && typeof update.filter.bandwidth_hz === "number") {
currentBandwidthHz = update.filter.bandwidth_hz;
syncBandwidthInput(currentBandwidthHz);
+ if (window.refreshCwTonePicker) {
+ window.refreshCwTonePicker();
+ }
if (
sdrGainEl
&& typeof update.filter.sdr_gain_db === "number"
@@ -2222,12 +2228,10 @@ function render(update) {
if (cwToneEl && typeof update.cw_tone_hz === "number") {
cwToneEl.value = update.cw_tone_hz;
}
- if (cwWpmEl && cwToneEl && typeof update.cw_auto === "boolean") {
+ if (cwWpmEl && typeof update.cw_auto === "boolean") {
const disabled = update.cw_auto;
cwWpmEl.disabled = disabled;
cwWpmEl.readOnly = disabled;
- cwToneEl.disabled = disabled;
- cwToneEl.readOnly = disabled;
}
let activeFreqColor = "var(--accent-green)";
if (update.status && update.status.vfo && Array.isArray(update.status.vfo.entries)) {
@@ -4965,6 +4969,9 @@ function startSpectrumStreaming() {
pushSpectrumPeakHoldFrame(lastSpectrumRenderData);
pushOverviewWaterfallFrame(lastSpectrumData);
refreshCenterFreqDisplay();
+ if (window.refreshCwTonePicker) {
+ window.refreshCwTonePicker();
+ }
scheduleSpectrumDraw();
if (lastModeName === "WFM") {
updateRdsPsOverlay(lastSpectrumData.rds);
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html
index 0105dca..4b4b0f3 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html
+++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html
@@ -550,10 +550,17 @@
-
+
+
+
+ CW Tone From Selected BW
+ --
+
+
+
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/cw.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/cw.js
index 7e3c7bc..2637cbf 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/cw.js
+++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/cw.js
@@ -5,7 +5,11 @@ 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 cwToneRangeEl = document.getElementById("cw-tone-range");
const CW_MAX_LINES = 200;
+const CW_TONE_MIN_HZ = 300;
+const CW_TONE_MAX_HZ = 1200;
function applyCwAutoUi(enabled) {
if (cwAutoInput) cwAutoInput.checked = enabled;
@@ -13,10 +17,92 @@ function applyCwAutoUi(enabled) {
cwWpmInput.disabled = enabled;
cwWpmInput.readOnly = enabled;
}
- if (cwToneInput) {
- cwToneInput.disabled = enabled;
- cwToneInput.readOnly = enabled;
+}
+
+function clampCwTone(tone) {
+ return Math.max(CW_TONE_MIN_HZ, Math.min(CW_TONE_MAX_HZ, Number(tone)));
+}
+
+function currentCwToneRange() {
+ const centerHz = Number.isFinite(window.lastFreqHz) ? Number(window.lastFreqHz) : Number.NaN;
+ const bandwidthHz = Number.isFinite(window.currentBandwidthHz) ? Number(window.currentBandwidthHz) : Number.NaN;
+ if (!Number.isFinite(centerHz) || !Number.isFinite(bandwidthHz) || bandwidthHz <= 0) {
+ return null;
}
+ return {
+ lowHz: centerHz - bandwidthHz / 2,
+ highHz: centerHz + bandwidthHz / 2,
+ centerHz,
+ bandwidthHz,
+ };
+}
+
+function drawCwTonePicker() {
+ if (!cwToneCanvas) return;
+ const ctx = cwToneCanvas.getContext("2d");
+ if (!ctx) return;
+
+ const width = cwToneCanvas.width;
+ const height = cwToneCanvas.height;
+ ctx.clearRect(0, 0, width, height);
+
+ const range = currentCwToneRange();
+ if (!range || !window.lastSpectrumData || !Array.isArray(window.lastSpectrumData.bins) || !window.lastSpectrumData.bins.length) {
+ if (cwToneRangeEl) cwToneRangeEl.textContent = "Waiting for spectrum";
+ ctx.fillStyle = "rgba(130, 150, 165, 0.22)";
+ ctx.fillRect(0, 0, width, height);
+ return;
+ }
+
+ if (cwToneRangeEl) {
+ const lowKHz = (range.lowHz / 1000).toFixed(range.bandwidthHz >= 10_000 ? 0 : 1);
+ const highKHz = (range.highHz / 1000).toFixed(range.bandwidthHz >= 10_000 ? 0 : 1);
+ cwToneRangeEl.textContent = `${lowKHz} - ${highKHz} kHz`;
+ }
+
+ 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(0);
+ let maxPower = 0;
+ for (let x = 0; x < width; x += 1) {
+ const frac = width <= 1 ? 0 : x / (width - 1);
+ const toneHz = range.lowHz + frac * (range.highHz - range.lowHz);
+ const idx = Math.max(0, Math.min(maxIdx, Math.round((((toneHz - fullLoHz) / sampleRate) * maxIdx))));
+ const power = Math.max(0, Number(bins[idx]) || 0);
+ tones[x] = power;
+ if (power > maxPower) maxPower = power;
+ }
+
+ for (let x = 0; x < width; x += 1) {
+ const frac = width <= 1 ? 0 : x / (width - 1);
+ const level = maxPower > 0 ? tones[x] / maxPower : 0;
+ const hue = 200 - level * 155;
+ const light = 18 + level * 55;
+ ctx.fillStyle = `hsl(${hue} 85% ${light}%)`;
+ ctx.fillRect(x, 0, 1, height);
+ }
+
+ const currentTone = clampCwTone(cwToneInput ? cwToneInput.value : 700);
+ const markerFrac = (currentTone - CW_TONE_MIN_HZ) / (CW_TONE_MAX_HZ - CW_TONE_MIN_HZ);
+ const markerX = Math.max(0, Math.min(width - 1, Math.round(markerFrac * (width - 1))));
+ ctx.fillStyle = "rgba(255, 255, 255, 0.9)";
+ ctx.fillRect(markerX, 0, 2, height);
+}
+
+async function setCwTone(tone, { syncInput = true } = {}) {
+ const clamped = clampCwTone(tone);
+ 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) {
@@ -40,17 +126,24 @@ if (cwWpmInput) {
if (cwToneInput) {
cwToneInput.addEventListener("change", async () => {
- if (cwAutoInput && cwAutoInput.checked) return;
- const tone = Math.max(300, Math.min(1200, Number(cwToneInput.value)));
- cwToneInput.value = tone;
- try { await postPath(`/set_cw_tone?tone_hz=${encodeURIComponent(tone)}`); }
- catch (e) { console.error("CW tone set failed", e); }
+ await setCwTone(cwToneInput.value);
+ });
+}
+
+if (cwToneCanvas) {
+ cwToneCanvas.addEventListener("click", async (event) => {
+ const rect = cwToneCanvas.getBoundingClientRect();
+ if (rect.width <= 0) return;
+ const frac = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
+ const tone = CW_TONE_MIN_HZ + frac * (CW_TONE_MAX_HZ - CW_TONE_MIN_HZ);
+ await setCwTone(tone);
});
}
window.resetCwHistoryView = function() {
cwOutputEl.innerHTML = "";
cwLastAppendTime = 0;
+ drawCwTonePicker();
};
document.getElementById("cw-clear-btn").addEventListener("click", async () => {
@@ -87,6 +180,12 @@ window.onServerCw = function(evt) {
cwSignalIndicator.className = evt.signal_on ? "cw-signal-on" : "cw-signal-off";
if (!cwAutoInput || cwAutoInput.checked) {
cwWpmInput.value = evt.wpm;
- cwToneInput.value = evt.tone_hz;
}
+ if (cwToneInput && Number.isFinite(Number(evt.tone_hz))) {
+ cwToneInput.value = clampCwTone(evt.tone_hz);
+ }
+ drawCwTonePicker();
};
+
+window.refreshCwTonePicker = drawCwTonePicker;
+drawCwTonePicker();
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css
index 74457f1..a0e057d 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css
+++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css
@@ -1536,6 +1536,9 @@ small { color: var(--text-muted); }
.cw-config { display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; margin-bottom: 0.75rem; }
.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-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-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-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-signal-on { width: 10px; height: 10px; border-radius: 50%; background: var(--accent-green); box-shadow: 0 0 6px var(--accent-green); flex-shrink: 0; }