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>
125 lines
4.3 KiB
Rust
125 lines
4.3 KiB
Rust
// 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()
|
|
);
|
|
}
|
|
}
|