[feat](trx-rs): make spectrum affordable over a slow link
CI / lint (pull_request) Successful in 2m22s
CI / test (pull_request) Successful in 8m36s
CI / frontend (push) Successful in 3m38s
CI / reuse (push) Successful in 6s
CI / frontend (pull_request) Successful in 4m27s
CI / reuse (pull_request) Successful in 6s
CI / lint (push) Successful in 2m21s
CI / test (push) Successful in 7m48s
CI / lint (pull_request) Successful in 2m22s
CI / test (pull_request) Successful in 8m36s
CI / frontend (push) Successful in 3m38s
CI / reuse (push) Successful in 6s
CI / frontend (pull_request) Successful in 4m27s
CI / reuse (pull_request) Successful in 6s
CI / lint (push) Successful in 2m21s
CI / test (push) Successful in 7m48s
Spectrum dominates the server↔client connection, and all three things that govern its cost were working against a poor link. **It was polled, one round trip per frame.** The client asked for a frame every 50 ms on a dedicated connection and waited for the reply, so the frame rate was capped at 1/RTT — on a 200 ms link, five frames a second no matter what was configured. Add SubscribeSpectrum alongside the existing SubscribeMeter: the server pushes frames from a per-rig broadcast that rig_task fills only while somebody is subscribed. A server too old to know the command answers with an error and leaves the connection usable, so the client falls back to polling on the same connection without reconnecting. **Bins were JSON floats.** 1024 bins spelled out as decimal text is around 10 KB a frame, ~200 KB/s at full rate — while the very next hop, client to browser, already sends the same information as base64 i8 in about 1.4 KB. Bins now travel base64-encoded whole dBFS, the resolution the display draws at anyway. Decoding still accepts the old array form. **Nothing was tunable.** [sdr].spectrum_fft_size and [sdr].spectrum_interval_ms replace the compile-time FFT size and cadence; [[remotes]].spectrum_interval_ms lets the client ask for less. 512 bins at 5 frames/s is roughly 3.5 KB/s against roughly 200 KB/s before. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7 Signed-off-by: Stan Grams <sjg@haxx.space>
This commit was merged in pull request #51.
This commit is contained in:
@@ -17,3 +17,4 @@ uuid = { workspace = true }
|
||||
ts-rs = { version = "12.0.1", features = ["uuid-impl"] }
|
||||
sgp4 = "2"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
|
||||
base64 = "0.22"
|
||||
|
||||
@@ -19,6 +19,7 @@ pub mod command;
|
||||
pub mod controller;
|
||||
pub mod request;
|
||||
pub mod response;
|
||||
pub mod spectrum_wire;
|
||||
pub mod state;
|
||||
|
||||
/// How this backend communicates with the rig.
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! Compact wire encoding for spectrum bins.
|
||||
//!
|
||||
//! Bins are dBFS magnitudes, and the web UI has always drawn them from `i8`
|
||||
//! values — the SSE hop to the browser quantizes and base64-encodes them. The
|
||||
//! server→client hop, which is the one that crosses the operator's network,
|
||||
//! used to send the same information as a JSON array of `f32`: around ten bytes
|
||||
//! per bin instead of one, or roughly 10 KB per 1024-bin frame.
|
||||
//!
|
||||
//! Bins therefore travel as base64-encoded `i8` dBFS, about an eighth of the
|
||||
//! size, at a resolution the display already rounds to. Decoding still accepts
|
||||
//! the old array form, so a new client can read an older server.
|
||||
|
||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||
use base64::Engine as _;
|
||||
use serde::de::{SeqAccess, Visitor};
|
||||
use serde::{Deserializer, Serializer};
|
||||
use std::fmt;
|
||||
|
||||
/// Quantize to whole dBFS and encode as base64.
|
||||
pub fn serialize<S: Serializer>(bins: &[f32], serializer: S) -> Result<S::Ok, S::Error> {
|
||||
let quantized: Vec<u8> = bins
|
||||
.iter()
|
||||
.map(|&db| {
|
||||
let clamped = if db.is_finite() { db } else { -128.0 };
|
||||
clamped.round().clamp(-128.0, 127.0) as i8 as u8
|
||||
})
|
||||
.collect();
|
||||
serializer.serialize_str(&BASE64.encode(quantized))
|
||||
}
|
||||
|
||||
/// Decode base64 bins, or a plain array of numbers from an older server.
|
||||
pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<f32>, D::Error> {
|
||||
deserializer.deserialize_any(BinsVisitor)
|
||||
}
|
||||
|
||||
struct BinsVisitor;
|
||||
|
||||
impl<'de> Visitor<'de> for BinsVisitor {
|
||||
type Value = Vec<f32>;
|
||||
|
||||
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("base64-encoded i8 dBFS bins, or an array of numbers")
|
||||
}
|
||||
|
||||
fn visit_str<E: serde::de::Error>(self, value: &str) -> Result<Self::Value, E> {
|
||||
let bytes = BASE64
|
||||
.decode(value)
|
||||
.map_err(|e| E::custom(format!("invalid base64 spectrum bins: {e}")))?;
|
||||
Ok(bytes.into_iter().map(|byte| byte as i8 as f32).collect())
|
||||
}
|
||||
|
||||
fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
|
||||
let mut bins = Vec::with_capacity(seq.size_hint().unwrap_or(1024));
|
||||
while let Some(value) = seq.next_element::<f32>()? {
|
||||
bins.push(value);
|
||||
}
|
||||
Ok(bins)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
||||
struct Frame {
|
||||
#[serde(with = "super")]
|
||||
bins: Vec<f32>,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_round_trip_quantizes_to_whole_db() {
|
||||
let frame = Frame {
|
||||
bins: vec![-73.4, -20.6, 0.0, -120.2],
|
||||
};
|
||||
let json = serde_json::to_string(&frame).unwrap();
|
||||
let back: Frame = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.bins, vec![-73.0, -21.0, 0.0, -120.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serializes_as_a_base64_string() {
|
||||
let json = serde_json::to_string(&Frame {
|
||||
bins: vec![-1.0, 0.0],
|
||||
})
|
||||
.unwrap();
|
||||
assert!(json.contains('"'), "bins should be a string: {json}");
|
||||
assert!(!json.contains('['), "bins should not be an array: {json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clamps_out_of_range_and_non_finite() {
|
||||
let frame = Frame {
|
||||
bins: vec![-400.0, 400.0, f32::NAN, f32::NEG_INFINITY],
|
||||
};
|
||||
let json = serde_json::to_string(&frame).unwrap();
|
||||
let back: Frame = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.bins, vec![-128.0, 127.0, -128.0, -128.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reads_the_old_array_form() {
|
||||
let back: Frame = serde_json::from_str(r#"{"bins":[-73.25,-20.5]}"#).unwrap();
|
||||
assert_eq!(back.bins, vec![-73.25, -20.5]);
|
||||
}
|
||||
|
||||
/// The point of the change: an ordinary frame gets much smaller.
|
||||
#[test]
|
||||
fn test_frame_is_far_smaller_than_the_array_form() {
|
||||
let bins: Vec<f32> = (0..1024).map(|i| -60.0 - (i % 40) as f32 * 0.37).collect();
|
||||
let compact = serde_json::to_string(&Frame { bins: bins.clone() }).unwrap();
|
||||
let verbose = serde_json::to_string(&bins).unwrap();
|
||||
assert!(
|
||||
compact.len() * 5 < verbose.len(),
|
||||
"compact {} bytes vs array {} bytes",
|
||||
compact.len(),
|
||||
verbose.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -394,6 +394,12 @@ fn default_wfm_denoise_level() -> WfmDenoiseLevel {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
|
||||
pub struct SpectrumData {
|
||||
/// FFT magnitude bins in dBFS, FFT-shifted so DC (centre frequency) is at index N/2.
|
||||
///
|
||||
/// On the wire these are base64-encoded `i8` whole dBFS (see
|
||||
/// `spectrum_wire`), which is what the display draws anyway; the TypeScript
|
||||
/// type describes the decoded array the browser receives over SSE.
|
||||
#[serde(with = "crate::rig::spectrum_wire")]
|
||||
#[ts(type = "Array<number>")]
|
||||
pub bins: Vec<f32>,
|
||||
/// Centre frequency of the SDR capture in Hz.
|
||||
#[ts(type = "number")]
|
||||
|
||||
Reference in New Issue
Block a user