[perf](trx-rs): optimize spectrum streaming and header overlay

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
2026-02-28 00:43:32 +01:00
parent adc17507ce
commit c703a5bafd
4 changed files with 54 additions and 27 deletions
+7 -7
View File
@@ -12,9 +12,9 @@ use tokio::time::{self, Instant};
use tracing::{info, warn};
use trx_core::rig::request::RigRequest;
use trx_core::rig::state::{RigState, SpectrumData};
use trx_core::rig::state::RigState;
use trx_core::{RigError, RigResult};
use trx_frontend::RemoteRigEntry;
use trx_frontend::{RemoteRigEntry, SharedSpectrum};
use trx_protocol::rig_command_to_client;
use trx_protocol::types::RigEntry;
use trx_protocol::{ClientCommand, ClientEnvelope, ClientResponse};
@@ -49,7 +49,7 @@ pub struct RemoteClientConfig {
pub known_rigs: Arc<Mutex<Vec<RemoteRigEntry>>>,
pub poll_interval: Duration,
/// Shared buffer updated by spectrum polling; None when backend has no spectrum.
pub spectrum: Arc<Mutex<Option<SpectrumData>>>,
pub spectrum: Arc<Mutex<SharedSpectrum>>,
}
pub async fn run_remote_client(
@@ -152,13 +152,13 @@ async fn handle_connection(
{
Ok(snapshot) => {
if let Ok(mut guard) = config.spectrum.lock() {
*guard = snapshot.spectrum;
guard.replace(snapshot.spectrum);
}
}
Err(_) => {
// Backend may not support spectrum; clear buffer silently.
if let Ok(mut guard) = config.spectrum.lock() {
*guard = None;
guard.replace(None);
}
}
}
@@ -674,7 +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)),
spectrum: Arc::new(Mutex::new(SharedSpectrum::default())),
},
req_rx,
state_tx,
@@ -710,7 +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)),
spectrum: Arc::new(Mutex::new(SharedSpectrum::default())),
};
let envelope = super::build_envelope(&config, trx_protocol::ClientCommand::GetState);
assert_eq!(envelope.token.as_deref(), Some("secret"));
+19 -2
View File
@@ -36,6 +36,23 @@ pub trait FrontendSpawner {
) -> JoinHandle<()>;
}
#[derive(Debug, Default)]
pub struct SharedSpectrum {
revision: u64,
frame: Option<SpectrumData>,
}
impl SharedSpectrum {
pub fn replace(&mut self, frame: Option<SpectrumData>) {
self.revision = self.revision.wrapping_add(1);
self.frame = frame;
}
pub fn snapshot(&self) -> (u64, Option<SpectrumData>) {
(self.revision, self.frame.clone())
}
}
pub type FrontendSpawnFn = fn(
watch::Receiver<RigState>,
mpsc::Sender<RigRequest>,
@@ -156,7 +173,7 @@ pub struct FrontendRuntimeContext {
/// Owner callsign from trx-client config/CLI for frontend display.
pub owner_callsign: Option<String>,
/// Latest spectrum frame from the active SDR rig; None for non-SDR backends.
pub spectrum: Arc<Mutex<Option<SpectrumData>>>,
pub spectrum: Arc<Mutex<SharedSpectrum>>,
}
impl FrontendRuntimeContext {
@@ -185,7 +202,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)),
spectrum: Arc::new(Mutex::new(SharedSpectrum::default())),
}
}
}
@@ -27,6 +27,7 @@
--wavelength-fg: #8da3be;
--spectrum-bg: #0a0f18;
--jog-wheel-size: 83.2px;
--header-waterfall-overlap: 3.2rem;
}
[data-theme="light"] {
@@ -378,10 +379,10 @@ small { color: var(--text-muted); }
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
column-gap: 1rem;
margin-bottom: -0.45rem;
margin-bottom: 0;
padding: 0.25rem 0 0.15rem;
position: relative;
z-index: 2;
z-index: 3;
}
.header-text {
width: auto;
@@ -391,17 +392,20 @@ small { color: var(--text-muted); }
.title { font-size: 1.4rem; font-weight: 700; display: inline-flex; align-items: center; gap: 0.35rem; }
.overview-strip {
width: 100%;
margin: 0 0 0.95rem;
padding-top: 0.8rem;
margin: calc(-1 * var(--header-waterfall-overlap)) 0 0.95rem;
position: relative;
z-index: 1;
}
.overview-toolbar {
display: flex;
align-items: center;
justify-content: flex-start;
justify-content: flex-end;
gap: 0.75rem;
margin-bottom: 0.25rem;
margin-bottom: 0;
position: absolute;
top: calc(var(--header-waterfall-overlap) + 0.2rem);
right: 0.15rem;
z-index: 2;
}
.overview-label {
margin-left: auto;
@@ -663,6 +667,7 @@ button:focus-visible, input:focus-visible, select:focus-visible {
.card { padding: 0.7rem 0.7rem 1rem; }
button { min-height: 2.8rem; font-size: 0.95rem; }
input.status-input, select.status-input { font-size: 1.1rem; }
:root { --header-waterfall-overlap: 2.2rem; }
.controls-tray { width: max(100%, 52rem); padding-left: 0.85rem; padding-right: 0.85rem; }
.freq-inline { gap: 0.5rem; }
.freq-inline { flex-wrap: wrap; }
@@ -672,7 +677,7 @@ button:focus-visible, input:focus-visible, select:focus-visible {
.top-bar-actions { width: 100%; justify-content: space-between; }
.header-rig-switch { width: auto; justify-content: flex-end; }
.header-rig-switch select { min-width: 6.5rem; }
.overview-toolbar { flex-wrap: wrap; }
.overview-toolbar { top: calc(var(--header-waterfall-overlap) + 0.15rem); }
.controls-row { grid-template-columns: 1fr auto; }
.controls-col-wfm { grid-column: 1 / -1; }
.controls-col-power { grid-column: 1 / -1; }
@@ -299,28 +299,33 @@ pub async fn spectrum(
context: web::Data<Arc<FrontendRuntimeContext>>,
) -> Result<HttpResponse, Error> {
let context_updates = context.get_ref().clone();
let mut last_json: Option<String> = None;
let mut last_revision: Option<u64> = None;
let updates =
IntervalStream::new(time::interval(Duration::from_millis(200))).filter_map(move |_| {
let context = context_updates.clone();
std::future::ready({
let next_json = context
let next = context
.spectrum
.lock()
.ok()
.and_then(|g| g.as_ref().and_then(|s| serde_json::to_string(s).ok()));
.map(|g| g.snapshot());
let payload = match (last_json.as_ref(), next_json) {
(Some(prev), Some(next)) if prev == &next => None,
(_, Some(next)) => {
last_json = Some(next.clone());
Some(next)
let payload = match next {
Some((revision, frame)) if last_revision == Some(revision) => None,
Some((revision, Some(frame))) => {
last_revision = Some(revision);
serde_json::to_string(&frame).ok()
}
(Some(_), None) => {
last_json = None;
Some((revision, None)) => {
last_revision = Some(revision);
Some("null".to_string())
}
(None, None) => None,
None if last_revision.is_some() => {
// Lock poisoning is transient; retry instead of breaking stream semantics.
last_revision = None;
Some("null".to_string())
}
None => None,
};
payload.map(|json| Ok::<Bytes, Error>(Bytes::from(format!("data: {json}\n\n"))))