Make spectrum affordable over a slow link #51

Merged
sjg merged 1 commits from feat/spectrum-transport into main 2026-08-07 00:23:33 +02:00
23 changed files with 894 additions and 21 deletions
Generated
+1
View File
@@ -3165,6 +3165,7 @@ dependencies = [
name = "trx-core"
version = "0.1.0"
dependencies = [
"base64",
"flate2",
"reqwest",
"serde",
+43
View File
@@ -153,6 +153,13 @@ When audio is enabled, at least one of `rx_enabled` or `tx_enabled` must be true
| `sample_rate` | u32 | `1920000` | IQ capture rate in Hz |
| `bandwidth` | u32 | `1500000` | Hardware IF filter bandwidth in Hz |
| `center_offset_hz` | i64 | `100000` | Offset from dial to avoid DC spur |
| `spectrum_fft_size` | usize | `1024` | Spectrum FFT bins; power of two, 1288192 |
| `spectrum_interval_ms` | u64 | `50` | How often a spectrum frame is pushed to subscribed clients |
Spectrum is the largest thing on the client connection. On a slow or
high-latency link, halving `spectrum_fft_size` halves the bytes per frame (at
half the frequency resolution) and raising `spectrum_interval_ms` sends fewer of
them; see [Spectrum over a slow link](#spectrum-over-a-slow-link).
#### `[sdr.gain]`
@@ -295,6 +302,7 @@ Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1
|-------|------|---------|-------------|
| `url` | string | — | Server address (e.g. `localhost:4530`) |
| `poll_interval_ms` | u64 | `750` | State poll interval |
| `spectrum_interval_ms` | u64 | `50` | Spectrum frame interval; also settable per `[[remotes]]` entry |
#### `[remote.auth]`
@@ -394,6 +402,41 @@ older single `port` key and `--rigctl-port` are ignored.
The bridge is intended for WSJT-X integration via virtual audio devices (ALSA
loopback on Linux, BlackHole on macOS).
### Spectrum over a slow link
Spectrum dominates the server↔client connection: everything else is a few
hundred bytes, a frame is a few kilobytes. Three things govern what it costs.
**Frames are pushed, not polled.** The client subscribes and the server sends
frames at `[sdr].spectrum_interval_ms`. Polling cost a round trip per frame, so
the rate was capped at 1/RTT — on a 200 ms link you could not exceed 5 frames a
second however often the client asked. Clients fall back to polling
automatically against a server too old to stream.
**Bins travel as whole dBFS.** They are base64-encoded `i8` on the wire, about
an eighth of the JSON array of floats they used to be, at the resolution the
display draws anyway.
**Both ends have a rate, and the slower one wins.** The server pushes no faster
than `[sdr].spectrum_interval_ms`; the client asks for no more than
`[[remotes]].spectrum_interval_ms`.
For a link that struggles, start here:
```toml
[trx-server.sdr]
spectrum_fft_size = 512 # half the bins, half the bytes
spectrum_interval_ms = 200 # 5 frames/s instead of 20
[[trx-client.remotes]]
name = "remote-site"
url = "radio.example.com:4530"
spectrum_interval_ms = 200
```
That is roughly 0.7 KB per frame at 5 frames/s — about 3.5 KB/s, against
roughly 200 KB/s for 1024 float bins at 20 frames/s.
### CLI Override Summary
**trx-server:**
+10
View File
@@ -288,6 +288,7 @@ async fn async_init() -> DynResult<AppState> {
let token = cli.token.clone().or_else(|| cfg.remote.auth.token.clone());
let poll_interval_ms = cli.poll_interval_ms.unwrap_or(cfg.remote.poll_interval_ms);
vec![RemoteEntry {
spectrum_interval_ms: cfg.remote.spectrum_interval_ms,
name,
url: url.clone(),
rig_id,
@@ -483,6 +484,13 @@ async fn async_init() -> DynResult<AppState> {
.map(|e| e.poll_interval_ms)
.min()
.unwrap_or(750);
// Entries sharing a server share its connections, so the most frequent
// request wins: whoever wants spectrum fastest sets the rate.
let spectrum_interval = entries
.iter()
.map(|e| e.spectrum_interval_ms)
.min()
.unwrap_or_else(|| remote_client::DEFAULT_SPECTRUM_INTERVAL.as_millis() as u64);
let (server_tx, server_rx) = mpsc::channel::<RigRequest>(RIG_TASK_CHANNEL_BUFFER);
for entry in entries {
@@ -496,6 +504,8 @@ async fn async_init() -> DynResult<AppState> {
known_rigs: frontend_runtime.routing.remote_rigs.clone(),
rig_states: frontend_runtime.routing.rig_states.clone(),
poll_interval: Duration::from_millis(poll_interval),
spectrum_interval: Duration::from_millis(spectrum_interval),
spectrum_stream_unsupported: Arc::new(std::sync::atomic::AtomicBool::new(false)),
spectrum: frontend_runtime.spectrum.sender.clone(),
rig_spectrums: frontend_runtime.spectrum.per_rig.clone(),
server_connected: frontend_runtime.routing.server_connected.clone(),
+271 -5
View File
@@ -20,7 +20,7 @@ use trx_core::{RigError, RigResult};
use trx_frontend::{RemoteRigEntry, SharedSpectrum};
use trx_protocol::rig_command_to_client;
use trx_protocol::types::RigEntry;
use trx_protocol::{ClientCommand, ClientEnvelope, ClientResponse, MeterUpdate};
use trx_protocol::{ClientCommand, ClientEnvelope, ClientResponse, MeterUpdate, SpectrumFrame};
// Endpoint parsing lives in `trx-config` so config validation and the
// connection code agree on what a URL means.
@@ -32,9 +32,10 @@ const SPECTRUM_IO_TIMEOUT: Duration = Duration::from_secs(3);
const MAX_JSON_LINE_BYTES: usize = 256 * 1024;
const MAX_CONSECUTIVE_POLL_FAILURES: u32 = 3;
// Keep remote spectrum reasonably responsive without returning to the old
// timeout churn caused by a much tighter request cadence.
const SPECTRUM_POLL_INTERVAL: Duration = Duration::from_millis(50);
// Default spectrum cadence when a config does not specify one. Both the push
// stream and the poll fallback run at the configured rate; see
// `[[remotes]].spectrum_interval_ms`.
pub const DEFAULT_SPECTRUM_INTERVAL: Duration = Duration::from_millis(50);
#[derive(Clone)]
pub struct RemoteClientConfig {
@@ -43,6 +44,12 @@ pub struct RemoteClientConfig {
pub selected_rig_id: Arc<Mutex<Option<String>>>,
pub known_rigs: Arc<Mutex<Vec<RemoteRigEntry>>>,
pub poll_interval: Duration,
/// How often spectrum frames are wanted. Drives the poll fallback and is
/// the rate the client asks the server to push at.
pub spectrum_interval: Duration,
/// Set once a server has rejected `SubscribeSpectrum`, so later
/// connections to it go straight to polling instead of asking again.
pub spectrum_stream_unsupported: Arc<AtomicBool>,
/// Spectrum watch sender; spectrum task publishes here, SSE clients subscribe.
pub spectrum: Arc<watch::Sender<SharedSpectrum>>,
/// Shared flag: `true` while a TCP connection to trx-server is active.
@@ -491,6 +498,100 @@ async fn send_get_sat_passes_on(
))
}
/// What ended a spectrum stream attempt.
enum SpectrumStreamOutcome {
/// The stream ran and is over; the connection is spent.
Finished,
/// The server rejected the subscription. The connection is still usable,
/// so the caller can poll on it.
Unsupported,
}
/// Subscribe to the server's spectrum push for one rig and publish frames as
/// they arrive.
///
/// The server answers either with frames or, when it is too old to know the
/// command, with an error response — which leaves the connection usable for
/// polling, so falling back costs no reconnect.
async fn run_spectrum_stream(
config: &RemoteClientConfig,
writer: &mut (impl AsyncWriteExt + Unpin),
reader: &mut (impl AsyncBufRead + Unpin),
short_name: &str,
shutdown_rx: &mut watch::Receiver<bool>,
) -> RigResult<SpectrumStreamOutcome> {
let envelope = build_envelope(
config,
ClientCommand::SubscribeSpectrum,
Some(short_name.to_string()),
);
let mut payload = serde_json::to_string(&envelope)
.map_err(|e| RigError::communication(format!("JSON serialize failed: {e}")))?;
payload.push('\n');
time::timeout(SPECTRUM_IO_TIMEOUT, writer.write_all(payload.as_bytes()))
.await
.map_err(|_| RigError::communication("spectrum subscribe write timed out".to_string()))?
.map_err(|e| RigError::communication(format!("spectrum subscribe write failed: {e}")))?;
time::timeout(SPECTRUM_IO_TIMEOUT, writer.flush())
.await
.map_err(|_| RigError::communication("spectrum subscribe flush timed out".to_string()))?
.map_err(|e| RigError::communication(format!("spectrum subscribe flush failed: {e}")))?;
// Re-check what the UI wants: switching rigs has to end this stream so the
// connection can be rebuilt for the new one.
let mut supervisor = time::interval(Duration::from_millis(500));
supervisor.tick().await;
loop {
tokio::select! {
changed = shutdown_rx.changed() => {
match changed {
Ok(()) if *shutdown_rx.borrow() => return Ok(SpectrumStreamOutcome::Finished),
Ok(()) => {}
Err(_) => return Ok(SpectrumStreamOutcome::Finished),
}
}
_ = supervisor.tick() => {
let wanted = active_spectrum_rig_ids(config);
if wanted.len() != 1 || wanted[0] != short_name {
return Ok(SpectrumStreamOutcome::Finished);
}
}
line = read_limited_line(reader, MAX_JSON_LINE_BYTES) => {
let line = line
.map_err(|e| RigError::communication(format!("spectrum read failed: {e}")))?
.ok_or_else(|| {
RigError::communication("spectrum connection closed".to_string())
})?;
let trimmed = line.trim_end();
if trimmed.is_empty() {
continue;
}
match serde_json::from_str::<SpectrumFrame>(trimmed) {
Ok(frame) => publish_spectrum_frame(config, short_name, frame),
// Anything that is not a frame means the server would rather
// answer than stream: an older build rejecting the command.
Err(_) => return Ok(SpectrumStreamOutcome::Unsupported),
}
}
}
}
}
/// Publish one pushed frame to the per-rig and selected-rig watch channels.
fn publish_spectrum_frame(config: &RemoteClientConfig, short_name: &str, frame: SpectrumFrame) {
if let Ok(map) = config.rig_spectrums.read() {
if let Some(tx) = map.get(short_name) {
tx.send_modify(|s| s.set(Some(frame.spectrum.clone()), frame.vchan_rds.clone()));
}
}
if selected_rig_id(config).as_deref() == Some(short_name) {
config
.spectrum
.send_modify(|s| s.set(Some(frame.spectrum), frame.vchan_rds));
}
}
async fn handle_spectrum_connection(
config: &RemoteClientConfig,
stream: TcpStream,
@@ -498,7 +599,33 @@ async fn handle_spectrum_connection(
) -> RigResult<()> {
let (reader, mut writer) = stream.into_split();
let mut reader = BufReader::new(reader);
let mut interval = time::interval(SPECTRUM_POLL_INTERVAL);
// Prefer the push stream: polling costs a round trip per frame, so on a
// high-latency link the frame rate is 1/RTT no matter what interval is
// configured. It only works for one rig per connection, and only against
// a server new enough to understand the command.
let streamable = active_spectrum_rig_ids(config);
if streamable.len() == 1 && !config.spectrum_stream_unsupported.load(Ordering::Relaxed) {
match run_spectrum_stream(
config,
&mut writer,
&mut reader,
&streamable[0],
shutdown_rx,
)
.await?
{
SpectrumStreamOutcome::Finished => return Ok(()),
SpectrumStreamOutcome::Unsupported => {
info!("Server does not support spectrum streaming; falling back to polling");
config
.spectrum_stream_unsupported
.store(true, Ordering::Relaxed);
}
}
}
let mut interval = time::interval(config.spectrum_interval);
// Cache the token outside the poll loop to avoid cloning it every 50ms.
let cached_token = config.token.clone();
@@ -1187,6 +1314,7 @@ mod tests {
use super::{has_short_names, resolve_server_rig_id, resolve_short_name};
use super::{
parse_audio_url, parse_remote_url, RemoteClientConfig, RemoteEndpoint, SharedSpectrum,
DEFAULT_SPECTRUM_INTERVAL,
};
use std::collections::HashMap;
use std::sync::atomic::AtomicBool;
@@ -1381,6 +1509,8 @@ 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_interval: DEFAULT_SPECTRUM_INTERVAL,
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
spectrum: Arc::new(spectrum_tx),
server_connected: Arc::new(AtomicBool::new(false)),
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
@@ -1426,6 +1556,8 @@ 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_interval: DEFAULT_SPECTRUM_INTERVAL,
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
spectrum: Arc::new(spectrum_tx),
server_connected: Arc::new(AtomicBool::new(false)),
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
@@ -1441,6 +1573,130 @@ mod tests {
assert_eq!(envelope.rig_id.as_deref(), Some("sdr"));
}
fn stream_test_config(spectrum_tx: watch::Sender<SharedSpectrum>) -> super::RemoteClientConfig {
super::RemoteClientConfig {
addr: "127.0.0.1:4530".to_string(),
token: None,
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_interval: DEFAULT_SPECTRUM_INTERVAL,
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
spectrum: Arc::new(spectrum_tx),
server_connected: Arc::new(AtomicBool::new(false)),
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
rig_states: Arc::new(RwLock::new(HashMap::new())),
rig_spectrums: Arc::new(RwLock::new(HashMap::new())),
rig_id_to_short_name: HashMap::new(),
short_name_to_rig_id: Arc::new(RwLock::new(HashMap::new())),
sat_passes: Arc::new(RwLock::new(None)),
rig_meters: Arc::new(RwLock::new(HashMap::new())),
}
}
/// A pushed frame reaches the watch channel the UI reads, with no request
/// from the client beyond the initial subscribe.
#[tokio::test]
async fn spectrum_stream_publishes_pushed_frames() {
use tokio::io::{AsyncBufReadExt, BufReader};
let (spectrum_tx, mut spectrum_rx) = watch::channel(SharedSpectrum::default());
let config = stream_test_config(spectrum_tx);
let (shutdown_tx, mut shutdown_rx) = watch::channel(false);
let (client_io, mut server_io) = tokio::io::duplex(64 * 1024);
let (client_read, mut client_write) = tokio::io::split(client_io);
let mut client_read = BufReader::new(client_read);
let task = tokio::spawn(async move {
super::run_spectrum_stream(
&config,
&mut client_write,
&mut client_read,
"sdr",
&mut shutdown_rx,
)
.await
});
// The server sees the subscribe, then pushes without being asked.
let mut server = BufReader::new(&mut server_io);
let mut subscribe = String::new();
server.read_line(&mut subscribe).await.expect("subscribe");
assert!(
subscribe.contains("subscribe_spectrum"),
"unexpected command: {subscribe}"
);
let frame = trx_protocol::SpectrumFrame {
rig_id: "sdr".to_string(),
spectrum: trx_core::rig::state::SpectrumData {
bins: vec![-70.0, -30.0],
center_hz: 14_200_000,
sample_rate: 1_920_000,
rds: None,
},
vchan_rds: None,
};
let mut line = serde_json::to_string(&frame).unwrap();
line.push('\n');
server_io.write_all(line.as_bytes()).await.expect("push");
server_io.flush().await.expect("flush");
spectrum_rx.changed().await.expect("spectrum published");
let published = spectrum_rx.borrow().clone();
let spectrum = published.frame.expect("spectrum present");
assert_eq!(spectrum.center_hz, 14_200_000);
assert_eq!(spectrum.bins, vec![-70.0, -30.0]);
let _ = shutdown_tx.send(true);
let _ = task.await;
}
/// An older server answers the unknown command with an error instead of
/// frames. That has to read as "poll instead", not as a dead connection.
#[tokio::test]
async fn spectrum_stream_falls_back_when_unsupported() {
use tokio::io::{AsyncBufReadExt, BufReader};
let (spectrum_tx, _spectrum_rx) = watch::channel(SharedSpectrum::default());
let config = stream_test_config(spectrum_tx);
let (_shutdown_tx, mut shutdown_rx) = watch::channel(false);
let (client_io, mut server_io) = tokio::io::duplex(64 * 1024);
let (client_read, mut client_write) = tokio::io::split(client_io);
let mut client_read = BufReader::new(client_read);
let task = tokio::spawn(async move {
super::run_spectrum_stream(
&config,
&mut client_write,
&mut client_read,
"sdr",
&mut shutdown_rx,
)
.await
});
let mut server = BufReader::new(&mut server_io);
let mut subscribe = String::new();
server.read_line(&mut subscribe).await.expect("subscribe");
server_io
.write_all(
b"{\"success\":false,\"state\":null,\"error\":\"Invalid JSON: unknown variant\"}\n",
)
.await
.expect("error response");
server_io.flush().await.expect("flush");
let outcome = task.await.expect("join").expect("stream result");
assert!(
matches!(outcome, super::SpectrumStreamOutcome::Unsupported),
"an error response should fall back to polling"
);
}
#[test]
fn build_envelope_translates_short_name_to_server_rig_id() {
let (spectrum_tx, _spectrum_rx) = watch::channel(SharedSpectrum::default());
@@ -1454,6 +1710,8 @@ mod tests {
selected_rig_id: Arc::new(Mutex::new(Some("home-hf".to_string()))),
known_rigs: Arc::new(Mutex::new(Vec::new())),
poll_interval: Duration::from_millis(500),
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
spectrum: Arc::new(spectrum_tx),
server_connected: Arc::new(AtomicBool::new(false)),
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
@@ -1486,6 +1744,8 @@ mod tests {
selected_rig_id: Arc::new(Mutex::new(None)),
known_rigs: Arc::new(Mutex::new(Vec::new())),
poll_interval: Duration::from_millis(500),
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
spectrum: Arc::new(spectrum_tx),
server_connected: Arc::new(AtomicBool::new(false)),
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
@@ -1510,6 +1770,8 @@ mod tests {
selected_rig_id: Arc::new(Mutex::new(None)),
known_rigs: Arc::new(Mutex::new(Vec::new())),
poll_interval: Duration::from_millis(500),
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
spectrum: Arc::new(spectrum_tx),
server_connected: Arc::new(AtomicBool::new(false)),
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
@@ -1550,6 +1812,8 @@ mod tests {
selected_rig_id: Arc::new(Mutex::new(None)),
known_rigs: known_rigs.clone(),
poll_interval: Duration::from_millis(500),
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
spectrum: Arc::new(spectrum_tx),
server_connected: Arc::new(AtomicBool::new(false)),
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
@@ -1623,6 +1887,8 @@ mod tests {
selected_rig_id,
known_rigs,
poll_interval: Duration::from_millis(500),
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
spectrum: Arc::new(spectrum_tx),
server_connected: Arc::new(AtomicBool::new(false)),
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
@@ -78,6 +78,10 @@ export type RdsData = { pi?: number | null, program_service?: string | null, rad
export type 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.
*/
bins: Array<number>,
/**
+76
View File
@@ -76,6 +76,8 @@ pub struct RemoteConfig {
pub auth: RemoteAuthConfig,
/// Poll interval in milliseconds.
pub poll_interval_ms: u64,
/// Spectrum frame interval in milliseconds.
pub spectrum_interval_ms: u64,
}
impl Default for RemoteConfig {
@@ -85,6 +87,7 @@ impl Default for RemoteConfig {
rig_id: None,
auth: RemoteAuthConfig::default(),
poll_interval_ms: 750,
spectrum_interval_ms: default_spectrum_interval_ms(),
}
}
}
@@ -121,12 +124,23 @@ pub struct RemoteEntry {
/// Poll interval in milliseconds. Defaults to 750.
#[serde(default = "default_poll_interval_ms")]
pub poll_interval_ms: u64,
/// How often spectrum frames are wanted from this server, in milliseconds.
/// Defaults to 50 (20 frames/s).
///
/// Raise it on a slow or high-latency link: spectrum is by far the largest
/// thing on the connection, and the server pushes no faster than this.
#[serde(default = "default_spectrum_interval_ms")]
pub spectrum_interval_ms: u64,
}
fn default_poll_interval_ms() -> u64 {
750
}
fn default_spectrum_interval_ms() -> u64 {
50
}
/// Frontend configurations.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
@@ -421,6 +435,7 @@ impl ClientConfig {
rig_id: self.remote.rig_id.clone(),
auth: self.remote.auth.clone(),
poll_interval_ms: self.remote.poll_interval_ms,
spectrum_interval_ms: self.remote.spectrum_interval_ms,
}]
} else {
Vec::new()
@@ -603,12 +618,21 @@ impl ClientConfig {
i, entry.name
));
}
if entry.spectrum_interval_ms == 0 {
return Err(format!(
"[[remotes]][{}].spectrum_interval_ms must be > 0 (name \"{}\")",
i, entry.name
));
}
}
// Legacy [remote], kept for backward compatibility.
if self.remote.poll_interval_ms == 0 {
return Err("[remote].poll_interval_ms must be > 0".to_string());
}
if self.remote.spectrum_interval_ms == 0 {
return Err("[remote].spectrum_interval_ms must be > 0".to_string());
}
if let Some(url) = &self.remote.url {
if url.trim().is_empty() {
return Err("[remote].url must not be empty when set".to_string());
@@ -834,6 +858,7 @@ impl ClientConfig {
token_file: None,
},
poll_interval_ms: 750,
spectrum_interval_ms: 50,
},
RemoteEntry {
name: "home-vhf".to_string(),
@@ -844,6 +869,7 @@ impl ClientConfig {
token_file: None,
},
poll_interval_ms: 750,
spectrum_interval_ms: 50,
},
],
frontends: FrontendsConfig {
@@ -994,6 +1020,7 @@ impl ConfigFile for ClientConfig {
rig_id: Some("hf".to_string()),
auth: RemoteAuthConfig::default(),
poll_interval_ms: default_poll_interval_ms(),
spectrum_interval_ms: 50,
}],
..Default::default()
};
@@ -1311,6 +1338,7 @@ url = "remote.example.com:4530"
rig_id: None,
auth: RemoteAuthConfig::default(),
poll_interval_ms: 750,
spectrum_interval_ms: 50,
}],
..Default::default()
};
@@ -1330,6 +1358,7 @@ url = "remote.example.com:4530"
token_file: None,
},
poll_interval_ms: 750,
spectrum_interval_ms: 50,
},
..Default::default()
};
@@ -1368,6 +1397,7 @@ url = "remote.example.com:4530"
rig_id: None,
auth: RemoteAuthConfig::default(),
poll_interval_ms: 750,
spectrum_interval_ms: 50,
}],
..Default::default()
};
@@ -1386,6 +1416,7 @@ url = "remote.example.com:4530"
rig_id: None,
auth: RemoteAuthConfig::default(),
poll_interval_ms: 750,
spectrum_interval_ms: 50,
},
RemoteEntry {
name: "dup".to_string(),
@@ -1393,6 +1424,7 @@ url = "remote.example.com:4530"
rig_id: None,
auth: RemoteAuthConfig::default(),
poll_interval_ms: 750,
spectrum_interval_ms: 50,
},
],
..Default::default()
@@ -1409,6 +1441,7 @@ url = "remote.example.com:4530"
rig_id: None,
auth: RemoteAuthConfig::default(),
poll_interval_ms: 750,
spectrum_interval_ms: 50,
}],
..Default::default()
};
@@ -1427,6 +1460,7 @@ url = "remote.example.com:4530"
rig_id: None,
auth: RemoteAuthConfig::default(),
poll_interval_ms: 750,
spectrum_interval_ms: 50,
}],
..Default::default()
};
@@ -1445,6 +1479,7 @@ url = "remote.example.com:4530"
rig_id: None,
auth: RemoteAuthConfig::default(),
poll_interval_ms: 0,
spectrum_interval_ms: 50,
}],
..Default::default()
};
@@ -1499,6 +1534,45 @@ url = "remote.example.com:4530"
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
}
// --- Spectrum cadence ---
#[test]
fn test_spectrum_interval_defaults_and_parses() {
let config: ClientConfig = toml::from_str(
r#"
[[remotes]]
name = "slow-link"
url = "host:4530"
spectrum_interval_ms = 500
[[remotes]]
name = "lan"
url = "host2:4530"
"#,
)
.unwrap();
assert_eq!(config.remotes[0].spectrum_interval_ms, 500);
assert_eq!(config.remotes[1].spectrum_interval_ms, 50);
assert!(config.validate().is_ok());
}
#[test]
fn test_validate_rejects_zero_spectrum_interval() {
let config: ClientConfig = toml::from_str(
r#"
[[remotes]]
name = "hf"
url = "host:4530"
spectrum_interval_ms = 0
"#,
)
.unwrap();
assert!(config
.validate()
.unwrap_err()
.contains("spectrum_interval_ms"));
}
// --- Secret indirection ---
fn secret_file(content: &str) -> tempfile::NamedTempFile {
@@ -1537,6 +1611,7 @@ url = "remote.example.com:4530"
token_file: Some(f.path().to_str().unwrap().to_string()),
},
poll_interval_ms: 750,
spectrum_interval_ms: 50,
}],
..Default::default()
};
@@ -1570,6 +1645,7 @@ url = "remote.example.com:4530"
rig_id: None,
auth: RemoteAuthConfig::default(),
poll_interval_ms: 750,
spectrum_interval_ms: 50,
})
.collect()
}
+130
View File
@@ -399,12 +399,30 @@ pub struct SdrConfig {
/// Default: 4.
#[serde(default = "default_max_virtual_channels")]
pub max_virtual_channels: usize,
/// FFT bin count for the spectrum display. Must be a power of two.
///
/// Halving it halves both the DSP cost and the bytes each frame puts on
/// the network, at half the frequency resolution — worth it on a slow link.
#[serde(default = "default_spectrum_fft_size")]
pub spectrum_fft_size: usize,
/// How often the server pushes a spectrum frame to a subscribed client, in
/// milliseconds. Raise it on a slow or metered link.
#[serde(default = "default_spectrum_interval_ms")]
pub spectrum_interval_ms: u64,
}
fn default_max_virtual_channels() -> usize {
4
}
fn default_spectrum_fft_size() -> usize {
1024
}
fn default_spectrum_interval_ms() -> u64 {
50
}
impl Default for SdrConfig {
fn default() -> Self {
Self {
@@ -417,6 +435,8 @@ impl Default for SdrConfig {
noise_blanker: SdrNoiseBlankerConfig::default(),
channels: Vec::new(),
max_virtual_channels: default_max_virtual_channels(),
spectrum_fft_size: default_spectrum_fft_size(),
spectrum_interval_ms: default_spectrum_interval_ms(),
}
}
}
@@ -913,6 +933,9 @@ fn validate_rig_instance(
errors.push(format!("{prefix}[sdr.gain].max_value must be >= 0"));
}
}
if let Err(e) = validate_spectrum_config(prefix, &rig.sdr) {
errors.push(e);
}
if let Err(e) = validate_sdr_squelch_config(&format!("{prefix}[sdr.squelch]"), &rig.sdr.squelch)
{
errors.push(e);
@@ -1070,6 +1093,26 @@ fn validate_access(prefix: &str, access: &AccessConfig) -> Result<(), String> {
Ok(())
}
/// A non-power-of-two FFT size still plans, but costs far more per frame; the
/// bounds keep the display useful without letting a typo allocate a huge FFT.
fn validate_spectrum_config(prefix: &str, sdr: &SdrConfig) -> Result<(), String> {
let size = sdr.spectrum_fft_size;
if !(128..=8192).contains(&size) {
return Err(format!(
"{prefix}[sdr].spectrum_fft_size must be in range 128..=8192"
));
}
if !size.is_power_of_two() {
return Err(format!(
"{prefix}[sdr].spectrum_fft_size must be a power of two (got {size})"
));
}
if sdr.spectrum_interval_ms == 0 {
return Err(format!("{prefix}[sdr].spectrum_interval_ms must be > 0"));
}
Ok(())
}
fn validate_sdr_squelch_config(path: &str, squelch: &SdrSquelchConfig) -> Result<(), String> {
if !squelch.threshold_db.is_finite() {
return Err(format!("{path}.threshold_db must be finite"));
@@ -1813,6 +1856,93 @@ port = 4531
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
}
// --- Spectrum transport knobs ---
#[test]
fn test_spectrum_defaults() {
let cfg = SdrConfig::default();
assert_eq!(cfg.spectrum_fft_size, 1024);
assert_eq!(cfg.spectrum_interval_ms, 50);
}
fn sdr_cfg_with_spectrum(fft_size: usize, interval_ms: u64) -> ServerConfig {
let mut cfg = ServerConfig::default();
cfg.rig.access.port = Some("/dev/ttyUSB0".to_string());
cfg.rig.access.baud = Some(9600);
cfg.sdr.spectrum_fft_size = fft_size;
cfg.sdr.spectrum_interval_ms = interval_ms;
cfg
}
#[test]
fn test_validate_accepts_smaller_power_of_two_fft() {
assert!(sdr_cfg_with_spectrum(256, 200).validate().is_ok());
}
#[test]
fn test_validate_rejects_non_power_of_two_fft() {
let err = sdr_cfg_with_spectrum(1000, 50)
.validate()
.expect_err("expected a power-of-two error");
assert!(err.contains("power of two"), "unexpected error: {err}");
}
#[test]
fn test_validate_rejects_out_of_range_fft() {
assert!(sdr_cfg_with_spectrum(64, 50).validate().is_err());
assert!(sdr_cfg_with_spectrum(16384, 50).validate().is_err());
}
#[test]
fn test_validate_rejects_zero_spectrum_interval() {
let err = sdr_cfg_with_spectrum(1024, 0)
.validate()
.expect_err("expected an interval error");
assert!(
err.contains("spectrum_interval_ms"),
"unexpected error: {err}"
);
}
/// Per-rig validation covers the spectrum knobs too, and names the rig.
#[test]
fn test_validate_rejects_bad_spectrum_size_in_rig_entry() {
let toml_str = r#"
[[rigs]]
id = "sdr"
[rigs.rig]
model = "soapysdr"
[rigs.rig.access]
type = "sdr"
args = "driver=rtlsdr"
[rigs.audio]
port = 4532
tx_enabled = false
[rigs.sdr]
spectrum_fft_size = 999
"#;
let cfg: ServerConfig = toml::from_str(toml_str).unwrap();
let err = cfg.validate().expect_err("expected a power-of-two error");
assert!(
err.contains("power of two") && err.contains("sdr"),
"unexpected error: {err}"
);
}
#[test]
fn test_parse_spectrum_config_from_toml() {
let cfg: ServerConfig = toml::from_str(
r#"
[sdr]
spectrum_fft_size = 512
spectrum_interval_ms = 250
"#,
)
.unwrap();
assert_eq!(cfg.sdr.spectrum_fft_size, 512);
assert_eq!(cfg.sdr.spectrum_interval_ms, 250);
}
// --- Secret indirection ---
#[test]
+1
View File
@@ -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"
+1
View File
@@ -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.
+124
View File
@@ -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()
);
}
}
+6
View File
@@ -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")]
+3 -1
View File
@@ -18,4 +18,6 @@ pub use auth::{NoAuthValidator, SimpleTokenValidator, TokenValidator};
pub use codec::{mode_to_string, parse_envelope, parse_mode};
pub use decoders::{DecoderActivation, DecoderDescriptor, DECODER_REGISTRY};
pub use mapping::{client_command_to_rig, rig_command_to_client};
pub use types::{ClientCommand, ClientEnvelope, ClientResponse, MeterUpdate, RigEntry};
pub use types::{
ClientCommand, ClientEnvelope, ClientResponse, MeterUpdate, RigEntry, SpectrumFrame,
};
+1 -1
View File
@@ -102,7 +102,7 @@ macro_rules! define_command_mapping {
define_command_mapping! {
// ── Client-only variants (no RigCommand counterpart) ─────────────
client_only: GetRigs, GetSatPasses, SubscribeMeter;
client_only: GetRigs, GetSatPasses, SubscribeMeter, SubscribeSpectrum;
// ── Unit variants (no payload) ───────────────────────────────────
unit:
+22
View File
@@ -130,6 +130,28 @@ pub enum ClientCommand {
/// newline-delimited `MeterUpdate` JSON frames and no further commands or
/// regular responses are sent. Intended for a dedicated TCP connection.
SubscribeMeter,
/// Subscribe to a per-rig spectrum stream on this connection. Like
/// `SubscribeMeter`, the connection becomes a one-way flow of
/// newline-delimited `SpectrumFrame` JSON and no further commands or
/// regular responses are sent.
///
/// Polling `GetSpectrum` costs a round trip per frame, which caps the frame
/// rate at 1/RTT no matter how often the client asks; the server pushes at
/// its own cadence instead. Clients fall back to polling when the server
/// rejects this command.
SubscribeSpectrum,
}
/// One spectrum frame pushed by the server on a dedicated spectrum stream.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SpectrumFrame {
/// Rig identifier this frame belongs to.
pub rig_id: String,
/// The frame itself; bins travel base64-encoded (see `spectrum_wire`).
pub spectrum: trx_core::rig::state::SpectrumData,
/// Virtual-channel RDS state, mirroring what `GetSpectrum` returned.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vchan_rds: Option<Vec<trx_core::rig::state::VchanRdsEntry>>,
}
/// Fast meter sample pushed by the server on a dedicated meter stream.
+117
View File
@@ -494,6 +494,58 @@ where
}
};
// SubscribeSpectrum: turns this connection into a one-way spectrum
// stream. Polling GetSpectrum costs a round trip per frame, so a
// client on a slow link could never reach the frame rate it asked for;
// pushing decouples the rate from the latency.
if matches!(envelope.cmd, ClientCommand::SubscribeSpectrum) {
let mut spectrum_rx = handle.spectrum_tx.subscribe();
let io_timeout = timeouts.io_timeout;
info!(
"Client {} subscribed to spectrum stream for rig '{}'",
addr, target_rig_id
);
loop {
tokio::select! {
frame = spectrum_rx.recv() => {
match frame {
Ok(frame) => {
let Ok(mut line) = serde_json::to_string(&frame) else { continue };
line.push('\n');
let write = time::timeout(
io_timeout,
writer.write_all(line.as_bytes()),
).await;
match write {
Ok(Ok(())) => {}
Ok(Err(e)) => {
info!("Client {} spectrum write failed: {}", addr, e);
break;
}
Err(_) => {
info!("Client {} spectrum write timed out", addr);
break;
}
}
}
// A client that cannot keep up skips to the newest
// frame; stale spectrum is not worth drawing.
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
changed = shutdown_rx.changed() => {
match changed {
Ok(()) if *shutdown_rx.borrow() => break,
Ok(()) => {}
Err(_) => break,
}
}
}
}
break;
}
// SubscribeMeter: turns this connection into a one-way meter stream.
// No regular responses are produced; the connection lives until the
// client disconnects or shutdown fires.
@@ -732,6 +784,7 @@ mod tests {
let (state_tx, state_rx) = watch::channel(state);
let _state_tx = state_tx;
let (meter_tx, _) = tokio::sync::broadcast::channel(8);
let (spectrum_tx, _) = tokio::sync::broadcast::channel(4);
let handle = RigHandle {
rig_id: "default".to_string(),
display_name: "Default Rig".to_string(),
@@ -739,6 +792,7 @@ mod tests {
state_rx,
audio_port: 4531,
meter_tx,
spectrum_tx,
};
let mut map = HashMap::new();
map.insert("default".to_string(), handle);
@@ -933,6 +987,7 @@ mod tests {
state_rx: state_rx_a,
audio_port: 4531,
meter_tx: meter_tx_a,
spectrum_tx: tokio::sync::broadcast::channel(4).0,
};
let (tx_b, rx_b) = mpsc::channel::<RigRequest>(8);
@@ -945,6 +1000,7 @@ mod tests {
state_rx: state_rx_b,
audio_port: 4532,
meter_tx: meter_tx_b,
spectrum_tx: tokio::sync::broadcast::channel(4).0,
};
let mut map = HashMap::new();
@@ -953,6 +1009,67 @@ mod tests {
(Arc::new(map), "rig_hf".to_string(), rx_a, rx_b)
}
/// Polling spectrum costs a round trip per frame, so the rate a client can
/// reach is capped by latency rather than by what it asked for. Subscribed
/// clients get frames pushed instead; this is that path end to end.
#[tokio::test]
async fn subscribe_spectrum_pushes_frames() {
use trx_core::rig::state::SpectrumData;
let (rigs, default_id) = make_rigs(sample_state());
let spectrum_tx = rigs.get("default").expect("rig").spectrum_tx.clone();
let ctx = make_ctx(rigs, default_id, HashSet::new());
let (mut reader, mut writer, handle, shutdown_tx) = spawn_client_io(ctx);
writer
.write_all(br#"{"cmd":"subscribe_spectrum"}"#)
.await
.expect("write");
writer.write_all(b"\n").await.expect("newline");
writer.flush().await.expect("flush");
// The subscription is registered asynchronously; publish until it takes.
let frame = trx_protocol::SpectrumFrame {
rig_id: "default".to_string(),
spectrum: SpectrumData {
bins: vec![-73.4, -20.6, 0.0],
center_hz: 14_200_000,
sample_rate: 1_920_000,
rds: None,
},
vchan_rds: None,
};
let mut line = String::new();
for _ in 0..50 {
let _ = spectrum_tx.send(frame.clone());
tokio::select! {
read = reader.read_line(&mut line) => {
if read.expect("read") > 0 && !line.trim().is_empty() {
break;
}
line.clear();
}
_ = tokio::time::sleep(Duration::from_millis(20)) => {}
}
}
let received: trx_protocol::SpectrumFrame =
serde_json::from_str(line.trim()).unwrap_or_else(|e| panic!("frame {line:?}: {e}"));
assert_eq!(received.rig_id, "default");
assert_eq!(received.spectrum.center_hz, 14_200_000);
// Bins survive the trip quantized to whole dBFS, which is the
// resolution the display draws at anyway.
assert_eq!(received.spectrum.bins, vec![-73.0, -21.0, 0.0]);
// And they travel as base64 rather than a JSON array of floats.
assert!(
!line.contains("-73"),
"bins should not be spelled out on the wire: {line}"
);
let _ = shutdown_tx.send(true);
let _ = handle.await;
}
#[tokio::test]
async fn multi_rig_state_isolation() {
let state_hf = sample_state_custom("HF-Dummy", 14_200_000, trx_core::RigMode::USB);
+8
View File
@@ -360,6 +360,7 @@ fn build_sdr_rig_from_instance(rig_cfg: &RigInstanceConfig) -> SdrRigBuildResult
max_virtual_channels: rig_cfg.sdr.max_virtual_channels,
nb_enabled: rig_cfg.sdr.noise_blanker.enabled,
nb_threshold: rig_cfg.sdr.noise_blanker.threshold,
spectrum_fft_size: rig_cfg.sdr.spectrum_fft_size,
})?;
let pcm_rx = sdr_rig.subscribe_pcm();
@@ -460,6 +461,7 @@ fn build_rig_task_config(
prebuilt_rig: None,
command_exec_timeout: Duration::from_millis(timeouts.command_exec_timeout_ms),
poll_refresh_timeout: Duration::from_millis(timeouts.poll_refresh_timeout_ms),
spectrum_interval_ms: rig_cfg.sdr.spectrum_interval_ms,
}
}
@@ -1215,6 +1217,9 @@ async fn main() -> DynResult<()> {
let (state_tx, state_rx) = watch::channel(initial_state);
let (meter_tx, _) =
broadcast::channel::<trx_protocol::MeterUpdate>(rig_handle::METER_BROADCAST_CAPACITY);
let (spectrum_tx, _) = broadcast::channel::<trx_protocol::SpectrumFrame>(
rig_handle::SPECTRUM_BROADCAST_CAPACITY,
);
let mut task_config = build_rig_task_config(
rig_cfg,
@@ -1242,12 +1247,14 @@ async fn main() -> DynResult<()> {
let rig_shutdown_rx = shutdown_rx.clone();
let rig_id_supervisor = rig_cfg.id.clone();
let meter_tx_task = meter_tx.clone();
let spectrum_tx_task = spectrum_tx.clone();
task_handles.push(tokio::spawn(async move {
let result = rig_task::run_rig_task(
task_config,
rig_rx,
state_tx.clone(),
meter_tx_task,
spectrum_tx_task,
rig_shutdown_rx,
)
.await;
@@ -1294,6 +1301,7 @@ async fn main() -> DynResult<()> {
rig_handles.insert(
rig_cfg.id.clone(),
RigHandle {
spectrum_tx: spectrum_tx.clone(),
rig_id: rig_cfg.id.clone(),
display_name: rig_cfg.display_name().to_string(),
rig_tx,
+10 -1
View File
@@ -8,13 +8,18 @@ use tokio::sync::{broadcast, mpsc, watch};
use trx_core::rig::request::RigRequest;
use trx_core::rig::state::RigState;
use trx_protocol::MeterUpdate;
use trx_protocol::{MeterUpdate, SpectrumFrame};
/// Bounded broadcast capacity for the meter stream. Keeps ~0.5 s of buffered
/// samples at 30 Hz — more than enough slack to tolerate a scheduling blip
/// without forcing the producer to block or drop silently.
pub const METER_BROADCAST_CAPACITY: usize = 16;
/// Bounded broadcast capacity for the spectrum stream. Frames are large and
/// only the newest one is worth drawing, so the buffer stays shallow: a slow
/// client lags and skips rather than making the server hold stale frames.
pub const SPECTRUM_BROADCAST_CAPACITY: usize = 4;
/// A handle to a single running rig backend.
///
/// One `RigHandle` is created per rig in `main.rs` and stored in the shared
@@ -34,4 +39,8 @@ pub struct RigHandle {
/// ~67 Hz (CAT). Consumed by `SubscribeMeter` clients; independent of
/// the slower `state_rx` snapshot path.
pub meter_tx: broadcast::Sender<MeterUpdate>,
/// Per-rig spectrum frames published by `rig_task` while at least one
/// client is subscribed. Consumed by `SubscribeSpectrum` clients; the
/// producer skips the work entirely when nobody is listening.
pub spectrum_tx: broadcast::Sender<SpectrumFrame>,
}
+27 -1
View File
@@ -23,7 +23,7 @@ use trx_core::rig::request::RigRequest;
use trx_core::rig::state::{RigMode, RigSnapshot, RigState};
use trx_core::rig::{RigCat, RigRxStatus, RigTxStatus};
use trx_core::{DynResult, RigError, RigResult};
use trx_protocol::MeterUpdate;
use trx_protocol::{MeterUpdate, SpectrumFrame};
use crate::audio::DecoderHistories;
use crate::error::is_invalid_bcd_error;
@@ -64,6 +64,8 @@ pub struct RigTaskConfig {
pub command_exec_timeout: Duration,
/// Maximum time for a CAT poll refresh cycle.
pub poll_refresh_timeout: Duration,
/// How often to push a spectrum frame to subscribed clients, in ms.
pub spectrum_interval_ms: u64,
}
impl Default for RigTaskConfig {
@@ -94,6 +96,7 @@ impl Default for RigTaskConfig {
prebuilt_rig: None,
command_exec_timeout: DEFAULT_COMMAND_EXEC_TIMEOUT,
poll_refresh_timeout: DEFAULT_POLL_REFRESH_TIMEOUT,
spectrum_interval_ms: 50,
}
}
}
@@ -115,6 +118,7 @@ pub async fn run_rig_task(
mut rx: mpsc::Receiver<RigRequest>,
state_tx: watch::Sender<RigState>,
meter_tx: broadcast::Sender<MeterUpdate>,
spectrum_tx: broadcast::Sender<SpectrumFrame>,
mut shutdown_rx: watch::Receiver<bool>,
) -> DynResult<()> {
let histories = config.histories.clone();
@@ -273,6 +277,13 @@ pub async fn run_rig_task(
} else {
Duration::from_millis(150)
};
// Spectrum frames get their own tick so a subscribed client never pays a
// round trip per frame. The FFT is computed by the SDR thread either way;
// this only reads the latest result, and only while somebody is subscribed.
let spectrum_tick_duration = Duration::from_millis(config.spectrum_interval_ms.max(1));
let mut spectrum_tick: std::pin::Pin<Box<tokio::time::Sleep>> =
Box::pin(tokio::time::sleep(spectrum_tick_duration));
let meter_task_start = Instant::now();
let meter_state_delta_db: f64 = 0.25;
let rig_id = config.rig_id.clone();
@@ -302,6 +313,21 @@ pub async fn run_rig_task(
Err(_) => break,
}
}
// Push the latest spectrum frame to subscribed clients.
_ = &mut spectrum_tick => {
spectrum_tick = Box::pin(tokio::time::sleep(spectrum_tick_duration));
// `send` fails only when nobody is listening, but building the
// frame clones a few KB of bins, so check before doing the work.
if spectrum_tx.receiver_count() > 0 {
if let Some(spectrum) = rig.as_sdr_ref().and_then(|s| s.get_spectrum()) {
let _ = spectrum_tx.send(SpectrumFrame {
rig_id: rig_id.clone(),
spectrum,
vchan_rds: rig.as_sdr_ref().and_then(|s| s.get_vchan_rds()),
});
}
}
}
// Fast meter-only refresh between full polls.
_ = &mut meter_tick => {
meter_tick = Box::pin(tokio::time::sleep(meter_tick_duration));
@@ -154,6 +154,7 @@ impl SdrPipeline {
squelch_cfg: VirtualSquelchConfig,
nb_cfg: NoiseBlankerConfig,
channels: &[(f64, RigMode, u32)],
spectrum_fft_size: usize,
) -> Self {
const IQ_BROADCAST_CAPACITY: usize = 64;
let (iq_tx, _iq_rx) = broadcast::channel::<Vec<Complex<f32>>>(IQ_BROADCAST_CAPACITY);
@@ -219,6 +220,7 @@ impl SdrPipeline {
.name("sdr-iq-read".to_string())
.spawn(move || {
iq_read_loop(
spectrum_fft_size,
source,
sdr_sample_rate,
thread_dsps,
@@ -317,6 +319,7 @@ pub const IQ_BLOCK_SIZE: usize = 4096;
#[allow(clippy::too_many_arguments)]
fn iq_read_loop(
spectrum_fft_size: usize,
mut source: Box<dyn IqSource>,
sdr_sample_rate: u32,
channel_dsps: Arc<RwLock<Vec<Arc<Mutex<ChannelDsp>>>>>,
@@ -335,7 +338,7 @@ fn iq_read_loop(
};
let throttle = !source.is_blocking();
let mut spectrum = SpectrumSnapshotter::new();
let mut spectrum = SpectrumSnapshotter::new(spectrum_fft_size);
let mut read_error_streak: u32 = 0;
let mut zero_read_streak: u32 = 0;
let mut overflow_log_window_start: Option<Instant> = None;
@@ -572,6 +575,7 @@ mod tests {
VirtualSquelchConfig::default(),
NoiseBlankerConfig::default(),
&[(200_000.0, RigMode::USB, 3000)],
1024,
);
assert_eq!(pipeline.pcm_senders.len(), 1);
assert_eq!(pipeline.channel_dsps.read().unwrap().len(), 1);
@@ -590,6 +594,7 @@ mod tests {
VirtualSquelchConfig::default(),
NoiseBlankerConfig::default(),
&[],
1024,
);
assert_eq!(pipeline.pcm_senders.len(), 0);
assert_eq!(pipeline.channel_dsps.read().unwrap().len(), 0);
@@ -9,28 +9,36 @@ use num_complex::Complex;
use rustfft::num_complex::Complex as FftComplex;
use rustfft::FftPlanner;
/// Number of FFT bins for the spectrum display.
pub(super) const SPECTRUM_FFT_SIZE: usize = 1024;
/// Default number of FFT bins for the spectrum display, used when the config
/// does not say otherwise.
pub(super) const DEFAULT_SPECTRUM_FFT_SIZE: usize = 1024;
/// Update the spectrum buffer every this many IQ blocks (~10 Hz at 1.92 MHz / 4096 block).
pub(super) const SPECTRUM_UPDATE_BLOCKS: usize = 4;
pub(super) struct SpectrumSnapshotter {
fft_size: usize,
hann_window: Vec<f32>,
fft: std::sync::Arc<dyn rustfft::Fft<f32>>,
counter: usize,
}
impl SpectrumSnapshotter {
pub(super) fn new() -> Self {
let hann_window: Vec<f32> = (0..SPECTRUM_FFT_SIZE)
.map(|i| 0.5 * (1.0 - (2.0 * PI * i as f32 / (SPECTRUM_FFT_SIZE - 1) as f32).cos()))
pub(super) fn new(fft_size: usize) -> Self {
let fft_size = if fft_size >= 2 {
fft_size
} else {
DEFAULT_SPECTRUM_FFT_SIZE
};
let hann_window: Vec<f32> = (0..fft_size)
.map(|i| 0.5 * (1.0 - (2.0 * PI * i as f32 / (fft_size - 1) as f32).cos()))
.collect();
let mut planner = FftPlanner::<f32>::new();
let fft = planner.plan_fft_forward(SPECTRUM_FFT_SIZE);
let fft = planner.plan_fft_forward(fft_size);
Self {
fft_size,
hann_window,
fft,
counter: 0,
@@ -48,7 +56,7 @@ impl SpectrumSnapshotter {
}
self.counter = 0;
let take = samples.len().min(SPECTRUM_FFT_SIZE);
let take = samples.len().min(self.fft_size);
let mut buf: Vec<FftComplex<f32>> = samples[..take]
.iter()
.enumerate()
@@ -59,16 +67,15 @@ impl SpectrumSnapshotter {
)
})
.collect();
buf.resize(SPECTRUM_FFT_SIZE, FftComplex::new(0.0, 0.0));
buf.resize(self.fft_size, FftComplex::new(0.0, 0.0));
self.fft.process(&mut buf);
let half = SPECTRUM_FFT_SIZE / 2;
let half = self.fft_size / 2;
let bins: Vec<f32> = buf[half..]
.iter()
.chain(buf[..half].iter())
.map(|value| {
let mag =
(value.re * value.re + value.im * value.im).sqrt() / SPECTRUM_FFT_SIZE as f32;
let mag = (value.re * value.re + value.im * value.im).sqrt() / self.fft_size as f32;
20.0 * mag.max(1e-10_f32).log10()
})
.collect();
@@ -73,6 +73,11 @@ pub struct SoapySdrConfig {
pub nb_enabled: bool,
/// Noise blanker impulse threshold multiplier.
pub nb_threshold: f64,
/// FFT bin count for the spectrum display; a power of two.
///
/// Fewer bins cost less DSP and put fewer bytes on the network per frame,
/// which is what a slow server↔client link cares about.
pub spectrum_fft_size: usize,
}
impl Default for SoapySdrConfig {
@@ -99,6 +104,7 @@ impl Default for SoapySdrConfig {
max_virtual_channels: 4,
nb_enabled: false,
nb_threshold: 10.0,
spectrum_fft_size: 1024,
}
}
}
@@ -194,6 +200,7 @@ impl SoapySdrRig {
let max_virtual_channels = config.max_virtual_channels;
let nb_enabled = config.nb_enabled;
let nb_threshold = config.nb_threshold;
let spectrum_fft_size = config.spectrum_fft_size;
tracing::info!(
"initialising SoapySDR backend (args={:?}, gain_mode={:?}, gain_db={}, max_gain_db={:?})",
args,
@@ -293,6 +300,7 @@ impl SoapySdrRig {
threshold: nb_threshold as f32,
},
&all_channels,
spectrum_fft_size,
));
let info = RigInfo {
@@ -418,6 +426,7 @@ impl SoapySdrRig {
nb_threshold: f64,
) -> DynResult<Self> {
Self::new_from_config(SoapySdrConfig {
spectrum_fft_size: 1024,
args: args.to_string(),
channels: channels.to_vec(),
gain_mode: gain_mode.to_string(),
@@ -439,6 +439,7 @@ mod tests {
VirtualSquelchConfig::default(),
NoiseBlankerConfig::default(),
&[(0.0, RigMode::USB, 3_000)],
1024,
))
}
+5
View File
@@ -120,6 +120,8 @@ wfm_deemphasis_us = 50
center_offset_hz = 100000
channels = []
max_virtual_channels = 4
spectrum_fft_size = 1024
spectrum_interval_ms = 50
# "auto" for hardware AGC, or "manual".
[trx-server.sdr.gain]
@@ -160,6 +162,7 @@ log_level = "info"
# Legacy single-remote form; prefer [[remotes]] below.
[trx-client.remote]
poll_interval_ms = 750
spectrum_interval_ms = 50
[trx-client.remote.auth]
@@ -168,6 +171,7 @@ name = "home-hf"
url = "192.168.1.100:4530"
rig_id = "hf"
poll_interval_ms = 750
spectrum_interval_ms = 50
[trx-client.remotes.auth]
token = "my-token"
@@ -177,6 +181,7 @@ name = "home-vhf"
url = "192.168.1.100:4530"
rig_id = "vhf"
poll_interval_ms = 750
spectrum_interval_ms = 50
[trx-client.remotes.auth]
token = "my-token"