From 091810c6cbdeb8f4531865ea57df2c0b81f9b64d Mon Sep 17 00:00:00 2001 From: Stanislaw Grams Date: Thu, 12 Feb 2026 22:47:37 +0100 Subject: [PATCH] [feat](trx-server): integrate wsprd-based WSPR decoding Add a new trx-wspr crate that wraps wsprd slot decoding and parsed results, wire it into the server audio pipeline, and emit WSPR decode events to clients. Also add frontend event routing for WSPR decode messages and temporary rendering in the FT8 table until a dedicated WSPR panel is introduced. Co-authored-by: Codex Signed-off-by: Stanislaw Grams --- Cargo.lock | 5 + Cargo.toml | 1 + .../trx-frontend-http/assets/web/app.js | 1 + .../assets/web/plugins/ft8.js | 12 ++ src/trx-server/Cargo.toml | 1 + src/trx-server/src/audio.rs | 80 ++++++-- src/trx-wspr/Cargo.toml | 10 + src/trx-wspr/src/lib.rs | 173 ++++++++++++++++++ 8 files changed, 271 insertions(+), 12 deletions(-) create mode 100644 src/trx-wspr/Cargo.toml create mode 100644 src/trx-wspr/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 0448f13..9ddc0e5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2334,8 +2334,13 @@ dependencies = [ "trx-core", "trx-ft8", "trx-protocol", + "trx-wspr", ] +[[package]] +name = "trx-wspr" +version = "0.1.0" + [[package]] name = "typenum" version = "1.19.0" diff --git a/Cargo.toml b/Cargo.toml index 8ebe41b..edc42cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ [workspace] members = [ "src/trx-ft8", + "src/trx-wspr", "src/trx-core", "src/trx-protocol", "src/trx-app", diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js index aec85e1..149c382 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js @@ -1267,6 +1267,7 @@ function connectDecode() { if (msg.type === "aprs" && window.onServerAprs) window.onServerAprs(msg); if (msg.type === "cw" && window.onServerCw) window.onServerCw(msg); if (msg.type === "ft8" && window.onServerFt8) window.onServerFt8(msg); + if (msg.type === "wspr" && window.onServerWspr) window.onServerWspr(msg); } catch (e) { // ignore parse errors } diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/ft8.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/ft8.js index 962edb8..f20aaf6 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/ft8.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/ft8.js @@ -149,3 +149,15 @@ window.onServerFt8 = function(msg) { message: msg.message, }); }; + +// Reuse FT8 table rendering for WSPR until a dedicated WSPR panel is added. +window.onServerWspr = function(msg) { + ft8Status.textContent = "Receiving"; + addFt8Message({ + ts_ms: msg.ts_ms, + snr_db: msg.snr_db, + dt_s: msg.dt_s, + freq_hz: msg.freq_hz, + message: `[WSPR] ${msg.message}`, + }); +}; diff --git a/src/trx-server/Cargo.toml b/src/trx-server/Cargo.toml index a1ade6e..d7b2b14 100644 --- a/src/trx-server/Cargo.toml +++ b/src/trx-server/Cargo.toml @@ -23,4 +23,5 @@ trx-app = { path = "../trx-app" } trx-backend = { path = "trx-backend" } trx-core = { path = "../trx-core" } trx-ft8 = { path = "../trx-ft8" } +trx-wspr = { path = "../trx-wspr" } trx-protocol = { path = "../trx-protocol" } diff --git a/src/trx-server/src/audio.rs b/src/trx-server/src/audio.rs index 72110d1..1964988 100644 --- a/src/trx-server/src/audio.rs +++ b/src/trx-server/src/audio.rs @@ -22,6 +22,7 @@ use trx_core::audio::{ use trx_core::decode::{AprsPacket, DecodedMessage, Ft8Message, WsprMessage}; use trx_core::rig::state::{RigMode, RigState}; use trx_ft8::Ft8Decoder; +use trx_wspr::WsprDecoder; use crate::config::AudioConfig; use crate::decode; @@ -124,6 +125,12 @@ pub fn clear_wspr_history() { history.clear(); } +pub fn record_wspr_message(msg: WsprMessage) { + let mut history = wspr_history().lock().expect("wspr history mutex poisoned"); + history.push_back((Instant::now(), msg)); + prune_wspr_history(&mut history); +} + /// Spawn the audio capture thread. /// /// Opens the configured input device via cpal, accumulates PCM samples into @@ -791,13 +798,22 @@ pub async fn run_wspr_decoder( channels: u16, mut pcm_rx: broadcast::Receiver>, mut state_rx: watch::Receiver, - _decode_tx: broadcast::Sender, + decode_tx: broadcast::Sender, ) { info!("WSPR decoder started ({}Hz, {} ch)", sample_rate, channels); + let decoder = match WsprDecoder::new() { + Ok(decoder) => decoder, + Err(err) => { + warn!("WSPR decoder init failed: {}", err); + return; + } + }; let mut last_reset_seq: u64 = 0; let mut active = state_rx.borrow().wspr_decode_enabled && matches!(state_rx.borrow().status.mode, RigMode::DIG | RigMode::USB); - let mut warned_no_decoder = false; + let mut slot_buf: Vec = Vec::new(); + let mut last_slot: i64 = -1; + let slot_len_s: i64 = 120; loop { if !active { @@ -812,7 +828,8 @@ pub async fn run_wspr_decoder( if state.wspr_decode_reset_seq != last_reset_seq { last_reset_seq = state.wspr_decode_reset_seq; } - warned_no_decoder = false; + slot_buf.clear(); + last_slot = -1; } Err(_) => break, } @@ -823,22 +840,58 @@ pub async fn run_wspr_decoder( recv = pcm_rx.recv() => { match recv { Ok(frame) => { + let now = match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) { + Ok(dur) => dur.as_secs() as i64, + Err(_) => 0, + }; + let slot = now / slot_len_s; + if last_slot == -1 { + last_slot = slot; + } else if slot != last_slot { + let base_freq = state_rx.borrow().status.freq.hz; + match decoder.decode_slot(&slot_buf, Some(base_freq)) { + Ok(results) => { + for res in results { + let ts_ms = match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) { + Ok(dur) => dur.as_millis() as i64, + Err(_) => 0, + }; + let msg = WsprMessage { + ts_ms, + snr_db: res.snr_db, + dt_s: res.dt_s, + freq_hz: res.freq_hz, + message: res.message, + }; + record_wspr_message(msg.clone()); + let _ = decode_tx.send(DecodedMessage::Wspr(msg)); + } + } + Err(err) => warn!("WSPR decode failed: {}", err), + } + slot_buf.clear(); + last_slot = slot; + } + let state = state_rx.borrow(); if state.wspr_decode_reset_seq != last_reset_seq { last_reset_seq = state.wspr_decode_reset_seq; + slot_buf.clear(); + last_slot = slot; } - // Keep the same preprocessing path as FT8 so decoder integration - // can be dropped in without changing task flow. let mono = downmix_mono(frame, channels); - if resample_to_12k(&mono, sample_rate).is_none() { + let Some(resampled) = resample_to_12k(&mono, sample_rate) else { warn!("WSPR decoder: unsupported sample rate {}", sample_rate); break; - } - - if !warned_no_decoder { - warn!("WSPR decoder engine not integrated yet; decode output is inactive"); - warned_no_decoder = true; + }; + slot_buf.extend_from_slice(&resampled); + if slot_buf.len() > decoder.slot_samples() { + let keep = decoder.slot_samples(); + let drain = slot_buf.len().saturating_sub(keep); + if drain > 0 { + slot_buf.drain(..drain); + } } } Err(broadcast::error::RecvError::Lagged(n)) => { @@ -855,11 +908,14 @@ pub async fn run_wspr_decoder( && matches!(state.status.mode, RigMode::DIG | RigMode::USB); if state.wspr_decode_reset_seq != last_reset_seq { last_reset_seq = state.wspr_decode_reset_seq; + slot_buf.clear(); + last_slot = -1; } if active { pcm_rx = pcm_rx.resubscribe(); } else { - warned_no_decoder = false; + slot_buf.clear(); + last_slot = -1; } } Err(_) => break, diff --git a/src/trx-wspr/Cargo.toml b/src/trx-wspr/Cargo.toml new file mode 100644 index 0000000..18e92ff --- /dev/null +++ b/src/trx-wspr/Cargo.toml @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: 2026 Stanislaw Grams +# +# SPDX-License-Identifier: BSD-2-Clause + +[package] +name = "trx-wspr" +version = "0.1.0" +edition = "2021" + +[dependencies] diff --git a/src/trx-wspr/src/lib.rs b/src/trx-wspr/src/lib.rs new file mode 100644 index 0000000..2283fbe --- /dev/null +++ b/src/trx-wspr/src/lib.rs @@ -0,0 +1,173 @@ +// SPDX-FileCopyrightText: 2026 Stanislaw Grams +// +// SPDX-License-Identifier: BSD-2-Clause + +use std::fs; +use std::io::Write; +use std::path::PathBuf; +use std::process::Command; + +const WSPR_SAMPLE_RATE: u32 = 12_000; +const SLOT_SAMPLES: usize = 120 * WSPR_SAMPLE_RATE as usize; + +#[derive(Debug, Clone)] +pub struct WsprDecodeResult { + pub message: String, + pub snr_db: f32, + pub dt_s: f32, + pub freq_hz: f32, +} + +pub struct WsprDecoder { + binary: String, +} + +impl WsprDecoder { + pub fn new() -> Result { + Ok(Self { + binary: "wsprd".to_string(), + }) + } + + pub fn sample_rate(&self) -> u32 { + WSPR_SAMPLE_RATE + } + + pub fn slot_samples(&self) -> usize { + SLOT_SAMPLES + } + + pub fn decode_slot( + &self, + samples: &[f32], + base_freq_hz: Option, + ) -> Result, String> { + if samples.len() < SLOT_SAMPLES { + return Ok(Vec::new()); + } + + let wav_path = self.write_temp_wav(samples)?; + let output = Command::new(&self.binary) + .arg(&wav_path) + .output() + .map_err(|e| format!("failed to run {}: {}", self.binary, e))?; + + let _ = fs::remove_file(&wav_path); + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!( + "wsprd failed with status {}: {}", + output.status, + stderr.trim() + )); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + Ok(parse_wsprd_output(&stdout, base_freq_hz)) + } + + fn write_temp_wav(&self, samples: &[f32]) -> Result { + let mut path = std::env::temp_dir(); + let unique = format!( + "trx-wspr-{}-{}.wav", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|e| e.to_string())? + .as_millis() + ); + path.push(unique); + + let mut file = fs::File::create(&path) + .map_err(|e| format!("failed to create temp wav {}: {}", path.display(), e))?; + + let num_samples = samples.len() as u32; + let data_bytes = num_samples * 2; + let riff_size = 36 + data_bytes; + let byte_rate = WSPR_SAMPLE_RATE * 2; + + file.write_all(b"RIFF").map_err(|e| e.to_string())?; + file.write_all(&riff_size.to_le_bytes()) + .map_err(|e| e.to_string())?; + file.write_all(b"WAVE").map_err(|e| e.to_string())?; + file.write_all(b"fmt ").map_err(|e| e.to_string())?; + file.write_all(&16u32.to_le_bytes()) + .map_err(|e| e.to_string())?; // PCM fmt size + file.write_all(&1u16.to_le_bytes()) + .map_err(|e| e.to_string())?; // PCM format + file.write_all(&1u16.to_le_bytes()) + .map_err(|e| e.to_string())?; // channels + file.write_all(&WSPR_SAMPLE_RATE.to_le_bytes()) + .map_err(|e| e.to_string())?; + file.write_all(&byte_rate.to_le_bytes()) + .map_err(|e| e.to_string())?; + file.write_all(&2u16.to_le_bytes()) + .map_err(|e| e.to_string())?; // block align + file.write_all(&16u16.to_le_bytes()) + .map_err(|e| e.to_string())?; // bits/sample + file.write_all(b"data").map_err(|e| e.to_string())?; + file.write_all(&data_bytes.to_le_bytes()) + .map_err(|e| e.to_string())?; + + for &sample in samples.iter().take(SLOT_SAMPLES) { + let clamped = sample.clamp(-1.0, 1.0); + let pcm = (clamped * i16::MAX as f32) as i16; + file.write_all(&pcm.to_le_bytes()) + .map_err(|e| e.to_string())?; + } + + Ok(path) + } +} + +fn parse_wsprd_output(output: &str, base_freq_hz: Option) -> Vec { + output + .lines() + .filter_map(|line| parse_wsprd_line(line, base_freq_hz)) + .collect() +} + +fn parse_wsprd_line(line: &str, base_freq_hz: Option) -> Option { + let fields: Vec<&str> = line.split_whitespace().collect(); + if fields.len() < 6 { + return None; + } + + let snr_db: f32 = fields.get(1)?.parse().ok()?; + let dt_s: f32 = fields.get(2)?.parse().ok()?; + let decoded_freq_hz: f32 = fields.get(3)?.parse().ok()?; + + let message = fields.iter().skip(5).copied().collect::>().join(" "); + if message.is_empty() { + return None; + } + + let freq_hz = if let Some(base) = base_freq_hz { + decoded_freq_hz - base as f32 + } else { + decoded_freq_hz + }; + + Some(WsprDecodeResult { + message, + snr_db, + dt_s, + freq_hz, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_line_basic() { + let line = "0001 -24 0.3 14097100 -1 CQ TEST FN20 37"; + let parsed = parse_wsprd_line(line, Some(14_097_000)).expect("parse"); + assert_eq!(parsed.message, "CQ TEST FN20 37"); + assert_eq!(parsed.snr_db, -24.0); + assert_eq!(parsed.dt_s, 0.3); + assert_eq!(parsed.freq_hz, 100.0); + } +}