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); + } +}