[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:
@@ -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"));
|
||||
|
||||
@@ -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"))))
|
||||
|
||||
Reference in New Issue
Block a user