[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
+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"))))