From 952961b9fd3b8c67028459c76b99074944807a7c Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Fri, 27 Feb 2026 21:36:05 +0100 Subject: [PATCH] feat(trx-client,http-frontend): spectrum waveform with frequency picker Poll GetSpectrum every 200 ms in remote_client via a dedicated timer that bypasses the main state-watch channel (no SSE noise). The resulting SpectrumData is stored in FrontendRuntimeContext::spectrum and served by a new GET /spectrum endpoint (JSON or 204 when unavailable). HTTP frontend shows a spectrum panel (canvas + frequency axis) only when the rig reports filter_controls=true (i.e. SoapySDR). The canvas renders: - dark background with dBFS grid lines - green FFT spectrum line with semi-transparent fill - red dashed vertical marker at the currently tuned frequency - frequency axis labels (MHz/kHz) below the canvas Clicking the canvas tunes the rig to the clicked frequency. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Stan Grams --- src/trx-client/src/main.rs | 1 + src/trx-client/src/remote_client.rs | 75 +++++++- src/trx-client/trx-frontend/src/lib.rs | 5 +- .../trx-frontend-http-json/src/server.rs | 1 + .../trx-frontend-http/assets/web/app.js | 180 ++++++++++++++++++ .../trx-frontend-http/assets/web/index.html | 7 + .../trx-frontend-http/assets/web/style.css | 28 +++ .../trx-frontend/trx-frontend-http/src/api.rs | 18 ++ .../trx-frontend-rigctl/src/server.rs | 1 + 9 files changed, 314 insertions(+), 2 deletions(-) diff --git a/src/trx-client/src/main.rs b/src/trx-client/src/main.rs index 0af5f4d..dcccb21 100644 --- a/src/trx-client/src/main.rs +++ b/src/trx-client/src/main.rs @@ -262,6 +262,7 @@ async fn async_init() -> DynResult { selected_rig_id: frontend_runtime.remote_active_rig_id.clone(), known_rigs: frontend_runtime.remote_rigs.clone(), poll_interval: Duration::from_millis(poll_interval_ms), + spectrum: frontend_runtime.spectrum.clone(), }; let remote_shutdown_rx = shutdown_rx.clone(); task_handles.push(tokio::spawn(async move { diff --git a/src/trx-client/src/remote_client.rs b/src/trx-client/src/remote_client.rs index 03ae89c..19ed3af 100644 --- a/src/trx-client/src/remote_client.rs +++ b/src/trx-client/src/remote_client.rs @@ -12,7 +12,7 @@ use tokio::time::{self, Instant}; use tracing::{info, warn}; use trx_core::rig::request::RigRequest; -use trx_core::rig::state::RigState; +use trx_core::rig::state::{RigState, SpectrumData}; use trx_core::{RigError, RigResult}; use trx_frontend::RemoteRigEntry; use trx_protocol::rig_command_to_client; @@ -40,12 +40,16 @@ impl RemoteEndpoint { } } +const SPECTRUM_POLL_INTERVAL: Duration = Duration::from_millis(200); + pub struct RemoteClientConfig { pub addr: String, pub token: Option, pub selected_rig_id: Arc>>, pub known_rigs: Arc>>, pub poll_interval: Duration, + /// Shared buffer updated by spectrum polling; None when backend has no spectrum. + pub spectrum: Arc>>, } pub async fn run_remote_client( @@ -107,6 +111,10 @@ async fn handle_connection( let mut reader = BufReader::new(reader); let mut poll_interval = time::interval(config.poll_interval); let mut last_poll = Instant::now(); + let mut spectrum_interval = time::interval(SPECTRUM_POLL_INTERVAL); + let mut last_spectrum_poll = Instant::now() + .checked_sub(SPECTRUM_POLL_INTERVAL) + .unwrap_or_else(Instant::now); // Prime rig list/state immediately after connect so frontends can render // rig selectors without waiting for the first poll interval. @@ -134,6 +142,27 @@ async fn handle_connection( warn!("Remote poll failed: {}", e); } } + _ = spectrum_interval.tick() => { + if last_spectrum_poll.elapsed() < SPECTRUM_POLL_INTERVAL { + continue; + } + last_spectrum_poll = Instant::now(); + match send_command_no_state_update(config, &mut writer, &mut reader, + ClientCommand::GetSpectrum).await + { + Ok(snapshot) => { + if let Ok(mut guard) = config.spectrum.lock() { + *guard = snapshot.spectrum; + } + } + Err(_) => { + // Backend may not support spectrum; clear buffer silently. + if let Ok(mut guard) = config.spectrum.lock() { + *guard = None; + } + } + } + } req = rx.recv() => { let Some(req) = req else { return Ok(()); @@ -196,6 +225,46 @@ async fn send_command( )) } +/// Like `send_command` but does NOT update the main `state_tx` watch channel. +/// Used for spectrum polling to avoid triggering spurious SSE updates. +async fn send_command_no_state_update( + config: &RemoteClientConfig, + writer: &mut tokio::net::tcp::OwnedWriteHalf, + reader: &mut BufReader, + cmd: ClientCommand, +) -> RigResult { + let envelope = build_envelope(config, cmd); + let payload = serde_json::to_string(&envelope) + .map_err(|e| RigError::communication(format!("JSON serialize failed: {e}")))?; + time::timeout( + IO_TIMEOUT, + writer.write_all(format!("{}\n", payload).as_bytes()), + ) + .await + .map_err(|_| RigError::communication(format!("write timed out after {:?}", IO_TIMEOUT)))? + .map_err(|e| RigError::communication(format!("write failed: {e}")))?; + time::timeout(IO_TIMEOUT, writer.flush()) + .await + .map_err(|_| RigError::communication(format!("flush timed out after {:?}", IO_TIMEOUT)))? + .map_err(|e| RigError::communication(format!("flush failed: {e}")))?; + let line = time::timeout(IO_TIMEOUT, read_limited_line(reader, MAX_JSON_LINE_BYTES)) + .await + .map_err(|_| RigError::communication(format!("read timed out after {:?}", IO_TIMEOUT)))? + .map_err(|e| RigError::communication(format!("read failed: {e}")))?; + let line = line.ok_or_else(|| RigError::communication("connection closed by remote"))?; + let resp: ClientResponse = serde_json::from_str(line.trim_end()) + .map_err(|e| RigError::communication(format!("invalid response: {e}")))?; + if resp.success { + if let Some(snapshot) = resp.state { + return Ok(snapshot); + } + return Err(RigError::communication("missing snapshot")); + } + Err(RigError::communication( + resp.error.unwrap_or_else(|| "remote error".into()), + )) +} + fn build_envelope(config: &RemoteClientConfig, cmd: ClientCommand) -> ClientEnvelope { ClientEnvelope { token: config.token.clone(), @@ -547,6 +616,7 @@ mod tests { cw_wpm: 15, cw_tone_hz: 700, filter: None, + spectrum: None, } } @@ -562,6 +632,7 @@ mod tests { state: None, rigs: Some(vec![RigEntry { rig_id: "default".to_string(), + display_name: None, state: snapshot.clone(), audio_port: Some(4531), }]), @@ -603,6 +674,7 @@ mod tests { selected_rig_id: Arc::new(Mutex::new(None)), known_rigs: Arc::new(Mutex::new(Vec::new())), poll_interval: Duration::from_millis(100), + spectrum: Arc::new(Mutex::new(None)), }, req_rx, state_tx, @@ -638,6 +710,7 @@ mod tests { selected_rig_id: Arc::new(Mutex::new(Some("sdr".to_string()))), known_rigs: Arc::new(Mutex::new(Vec::new())), poll_interval: Duration::from_millis(500), + spectrum: Arc::new(Mutex::new(None)), }; let envelope = super::build_envelope(&config, trx_protocol::ClientCommand::GetState); assert_eq!(envelope.token.as_deref(), Some("secret")); diff --git a/src/trx-client/trx-frontend/src/lib.rs b/src/trx-client/trx-frontend/src/lib.rs index 1a44563..a22c8a0 100644 --- a/src/trx-client/trx-frontend/src/lib.rs +++ b/src/trx-client/trx-frontend/src/lib.rs @@ -14,7 +14,7 @@ use tokio::task::JoinHandle; use trx_core::audio::AudioStreamInfo; use trx_core::decode::{AprsPacket, CwEvent, DecodedMessage, Ft8Message, WsprMessage}; -use trx_core::rig::state::RigSnapshot; +use trx_core::rig::state::{RigSnapshot, SpectrumData}; use trx_core::{DynResult, RigRequest, RigState}; #[derive(Clone, Debug)] @@ -155,6 +155,8 @@ pub struct FrontendRuntimeContext { pub remote_rigs: Arc>>, /// Owner callsign from trx-client config/CLI for frontend display. pub owner_callsign: Option, + /// Latest spectrum frame from the active SDR rig; None for non-SDR backends. + pub spectrum: Arc>>, } impl FrontendRuntimeContext { @@ -183,6 +185,7 @@ impl FrontendRuntimeContext { remote_active_rig_id: Arc::new(Mutex::new(None)), remote_rigs: Arc::new(Mutex::new(Vec::new())), owner_callsign: None, + spectrum: Arc::new(Mutex::new(None)), } } } diff --git a/src/trx-client/trx-frontend/trx-frontend-http-json/src/server.rs b/src/trx-client/trx-frontend/trx-frontend-http-json/src/server.rs index 20c77ac..d6fd6aa 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http-json/src/server.rs +++ b/src/trx-client/trx-frontend/trx-frontend-http-json/src/server.rs @@ -410,6 +410,7 @@ mod tests { cw_wpm: 15, cw_tone_hz: 700, filter: None, + spectrum: None, } } 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 6c0f75d..4c099d3 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 @@ -261,6 +261,18 @@ function applyCapabilities(caps) { // Filters panel const filtersPanel = document.getElementById("filters-panel"); if (filtersPanel) filtersPanel.style.display = caps.filter_controls ? "" : "none"; + + // Spectrum panel (SDR-only) + const spectrumPanel = document.getElementById("spectrum-panel"); + if (spectrumPanel) { + if (caps.filter_controls) { + spectrumPanel.style.display = ""; + startSpectrumPolling(); + } else { + spectrumPanel.style.display = "none"; + stopSpectrumPolling(); + } + } } const freqEl = document.getElementById("freq"); @@ -2329,3 +2341,171 @@ window.addEventListener("beforeunload", () => { navigator.sendBeacon("/set_ptt?ptt=false", ""); } }); + +// ── Spectrum display ──────────────────────────────────────────────────────── +const spectrumCanvas = document.getElementById("spectrum-canvas"); +const spectrumFreqAxis = document.getElementById("spectrum-freq-axis"); +let spectrumPollTimer = null; +let lastSpectrumData = null; + +function startSpectrumPolling() { + if (spectrumPollTimer !== null) return; + spectrumPollTimer = setInterval(fetchSpectrum, 200); + fetchSpectrum(); +} + +function stopSpectrumPolling() { + if (spectrumPollTimer !== null) { + clearInterval(spectrumPollTimer); + spectrumPollTimer = null; + } + lastSpectrumData = null; + clearSpectrumCanvas(); +} + +async function fetchSpectrum() { + try { + const resp = await fetch("/spectrum", { cache: "no-store" }); + if (resp.status === 204) { + lastSpectrumData = null; + clearSpectrumCanvas(); + return; + } + if (!resp.ok) return; + const data = await resp.json(); + lastSpectrumData = data; + drawSpectrum(data); + } catch (_) { + // ignore fetch errors (connection lost etc.) + } +} + +function clearSpectrumCanvas() { + if (!spectrumCanvas) return; + const ctx = spectrumCanvas.getContext("2d"); + const w = spectrumCanvas.width, h = spectrumCanvas.height; + ctx.clearRect(0, 0, w, h); + ctx.fillStyle = "#0a0f18"; + ctx.fillRect(0, 0, w, h); +} + +function drawSpectrum(data) { + if (!spectrumCanvas) return; + const dpr = window.devicePixelRatio || 1; + const cssW = spectrumCanvas.clientWidth || 600; + const cssH = spectrumCanvas.clientHeight || 120; + const W = Math.round(cssW * dpr); + const H = Math.round(cssH * dpr); + if (spectrumCanvas.width !== W || spectrumCanvas.height !== H) { + spectrumCanvas.width = W; + spectrumCanvas.height = H; + } + + const ctx = spectrumCanvas.getContext("2d"); + // Background + ctx.fillStyle = "#0a0f18"; + ctx.fillRect(0, 0, W, H); + + const bins = data.bins; + const n = bins.length; + if (!n) return; + + // dBFS range for display + const DB_MIN = -80; + const DB_MAX = 0; + const dbRange = DB_MAX - DB_MIN; + + // Grid lines (horizontal dBFS) + ctx.strokeStyle = "rgba(255,255,255,0.06)"; + ctx.lineWidth = 1; + for (let db = DB_MIN; db <= DB_MAX; db += 20) { + const y = Math.round(H * (1 - (db - DB_MIN) / dbRange)); + ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke(); + } + + // Spectrum line + ctx.beginPath(); + ctx.strokeStyle = "#00e676"; + ctx.lineWidth = Math.max(1, dpr); + for (let i = 0; i < n; i++) { + const x = (i / (n - 1)) * W; + const db = Math.max(DB_MIN, Math.min(DB_MAX, bins[i])); + const y = H * (1 - (db - DB_MIN) / dbRange); + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); + + // Fill under spectrum line + ctx.lineTo(W, H); ctx.lineTo(0, H); ctx.closePath(); + ctx.fillStyle = "rgba(0,230,118,0.08)"; + ctx.fill(); + + // Tuned-frequency marker + if (lastFreqHz != null && data.center_hz && data.sample_rate) { + const halfBw = data.sample_rate / 2; + const loHz = data.center_hz - halfBw; + const hiHz = data.center_hz + halfBw; + const frac = (lastFreqHz - loHz) / (hiHz - loHz); + if (frac >= 0 && frac <= 1) { + const xf = Math.round(frac * W); + ctx.save(); + ctx.setLineDash([4 * dpr, 4 * dpr]); + ctx.strokeStyle = "#ff1744"; + ctx.lineWidth = Math.max(1, dpr); + ctx.beginPath(); ctx.moveTo(xf, 0); ctx.lineTo(xf, H); ctx.stroke(); + ctx.restore(); + } + } + + // Frequency axis labels + updateSpectrumFreqAxis(data); +} + +function updateSpectrumFreqAxis(data) { + if (!spectrumFreqAxis || !data.center_hz || !data.sample_rate) return; + const halfBw = data.sample_rate / 2; + const loHz = data.center_hz - halfBw; + const hiHz = data.center_hz + halfBw; + + // Choose label step: aim for ~5 labels + const spanMHz = (hiHz - loHz) / 1e6; + let stepMHz = 1; + if (spanMHz <= 1) stepMHz = 0.1; + else if (spanMHz <= 2) stepMHz = 0.2; + else if (spanMHz <= 5) stepMHz = 0.5; + else if (spanMHz <= 10) stepMHz = 1; + else if (spanMHz <= 20) stepMHz = 2; + else stepMHz = 5; + + const stepHz = stepMHz * 1e6; + const firstHz = Math.ceil(loHz / stepHz) * stepHz; + + // Rebuild axis spans + spectrumFreqAxis.innerHTML = ""; + for (let hz = firstHz; hz <= hiHz; hz += stepHz) { + const frac = (hz - loHz) / (hiHz - loHz); + const pct = (frac * 100).toFixed(2); + const label = hz >= 1e6 + ? (hz / 1e6).toFixed(stepMHz < 1 ? 1 : 0) + " MHz" + : (hz / 1e3).toFixed(0) + " kHz"; + const span = document.createElement("span"); + span.textContent = label; + span.style.left = pct + "%"; + spectrumFreqAxis.appendChild(span); + } +} + +// Click on spectrum canvas → tune to that frequency +if (spectrumCanvas) { + spectrumCanvas.addEventListener("click", (e) => { + if (!lastSpectrumData || !lastSpectrumData.center_hz || !lastSpectrumData.sample_rate) return; + const rect = spectrumCanvas.getBoundingClientRect(); + const frac = (e.clientX - rect.left) / rect.width; + const halfBw = lastSpectrumData.sample_rate / 2; + const loHz = lastSpectrumData.center_hz - halfBw; + const hiHz = lastSpectrumData.center_hz + halfBw; + const targetHz = Math.round(loHz + frac * (hiHz - loHz)); + setFreq(targetHz); + }); +} 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 6a84f69..5a0a487 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 @@ -158,6 +158,13 @@ +
Audio
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 9ffe6b9..50b4f82 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 @@ -583,3 +583,31 @@ button:focus-visible, input:focus-visible, select:focus-visible { .vfo-picker button { border-right: none; border-bottom: 1px solid var(--border-light); } .vfo-picker button:last-child { border-bottom: none; } } + + +/* ── Spectrum display ─────────────────────────────────────────────────── */ +.spectrum-wrap { + position: relative; + width: 100%; +} +#spectrum-canvas { + display: block; + width: 100%; + height: 120px; + background: #0a0f18; + border-radius: 4px; + cursor: crosshair; +} +#spectrum-freq-axis { + position: relative; + height: 18px; + width: 100%; + font-size: 0.7rem; + color: var(--text-muted); + user-select: none; +} +#spectrum-freq-axis span { + position: absolute; + transform: translateX(-50%); + white-space: nowrap; +} diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs index a4ec76b..54fabfc 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs +++ b/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs @@ -291,6 +291,22 @@ impl futures_util::Stream for DropStream { } } +/// Lightweight polling endpoint for spectrum data. +/// Returns the latest `SpectrumData` as JSON, or 204 No Content if unavailable. +#[get("/spectrum")] +pub async fn spectrum( + context: web::Data>, +) -> Result { + let data = context.spectrum.lock().ok().and_then(|g| g.clone()); + match data { + Some(s) => Ok(HttpResponse::Ok() + .insert_header((header::CONTENT_TYPE, "application/json")) + .insert_header((header::CACHE_CONTROL, "no-cache")) + .json(s)), + None => Ok(HttpResponse::NoContent().finish()), + } +} + #[post("/toggle_power")] pub async fn toggle_power( state: web::Data>, @@ -611,6 +627,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) { .service(list_rigs) .service(events) .service(decode_events) + .service(spectrum) .service(toggle_power) .service(toggle_vfo) .service(lock_panel) @@ -805,6 +822,7 @@ async fn wait_for_view(mut rx: watch::Receiver) -> Result