From 4f01db080159a324a0fff3628a43c6fb9efab94d Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Wed, 4 Mar 2026 22:49:52 +0100 Subject: [PATCH] [fix](trx-frontend): render CW picker as audio spectrum trace Render the CW tone picker as an audio-frequency spectrum trace with grid, line and filled area instead of a normalized gradient wash. Keep exact click-to-tone selection and marker behavior. Co-authored-by: Codex Signed-off-by: Stan Grams --- .../assets/web/plugins/cw.js | 99 ++++++++++++++++--- 1 file changed, 87 insertions(+), 12 deletions(-) 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 5f924d2..b207f98 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 @@ -160,9 +160,7 @@ function drawCwTonePicker() { 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; - let minPower = Number.POSITIVE_INFINITY; + 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; @@ -170,24 +168,101 @@ function drawCwTonePicker() { 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; - if (power > maxPower) maxPower = power; - if (power < minPower) minPower = power; } - const powerSpan = Math.max(1, maxPower - minPower); + const smoothed = new Array(width).fill(-140); + const smoothRadius = Math.max(1, Math.round(width / 180)); for (let x = 0; x < width; x += 1) { - const level = Math.max(0, Math.min(1, (tones[x] - minPower) / powerSpan)); - const hue = 200 - level * 155; - const light = 14 + Math.pow(level, 0.75) * 58; - ctx.fillStyle = `hsl(${hue} 85% ${light}%)`; - ctx.fillRect(x, 0, 1, height); + 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 axisColor = "rgba(230, 235, 245, 0.15)"; + const textColor = "rgba(230, 235, 245, 0.58)"; + + ctx.fillStyle = "rgba(7, 12, 18, 0.94)"; + ctx.fillRect(0, 0, width, height); + + const hGridCount = 4; + ctx.strokeStyle = axisColor; + ctx.lineWidth = 1; + for (let i = 1; i <= hGridCount; i += 1) { + const y = Math.round((i / (hGridCount + 1)) * (height - 1)) + 0.5; + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(width, y); + ctx.stroke(); + } + + const toneStep = range.toneSpanHz <= 500 ? 50 : range.toneSpanHz <= 1000 ? 100 : 200; + const firstTick = Math.ceil(range.toneMinHz / toneStep) * toneStep; + ctx.font = `${Math.max(10, Math.round(height * 0.18))}px ui-monospace, SFMono-Regular, Menlo, monospace`; + ctx.fillStyle = textColor; + 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)))) + 0.5; + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, height); + ctx.stroke(); + if (tone % (toneStep * 2) === 0) { + const label = `${Math.round(tone)}`; + const textWidth = ctx.measureText(label).width; + ctx.fillText(label, Math.max(1, Math.min(width - textWidth - 1, x + 2)), height - 3); + } + } + + ctx.beginPath(); + ctx.moveTo(0, height - 0.5); + for (let x = 0; x < width; x += 1) { + ctx.lineTo(x + 0.5, yForDb(smoothed[x]) + 0.5); + } + ctx.lineTo(width - 0.5, height - 0.5); + ctx.closePath(); + ctx.save(); + ctx.globalAlpha = 0.24; + ctx.fillStyle = accent; + ctx.fill(); + ctx.restore(); + + ctx.beginPath(); + for (let x = 0; x < width; x += 1) { + const y = yForDb(smoothed[x]) + 0.5; + if (x === 0) ctx.moveTo(0.5, y); + else ctx.lineTo(x + 0.5, y); + } + ctx.lineWidth = 1.8; + ctx.strokeStyle = accent; + ctx.stroke(); + 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))]); ctx.fillStyle = "rgba(255, 255, 255, 0.9)"; - ctx.fillRect(markerX, 0, 2, height); + ctx.fillRect(markerX, 0, 1.5, height); + ctx.beginPath(); + ctx.arc(markerX, markerY, Math.max(2, Math.round(height * 0.055)), 0, Math.PI * 2); + ctx.fill(); if (cwAutoInput?.checked) { ctx.fillStyle = "rgba(0, 0, 0, 0.22)";