diff --git a/src/trx-wspr/src/decoder.rs b/src/trx-wspr/src/decoder.rs new file mode 100644 index 0000000..4800b60 --- /dev/null +++ b/src/trx-wspr/src/decoder.rs @@ -0,0 +1,160 @@ +// SPDX-FileCopyrightText: 2026 Stanislaw Grams +// +// SPDX-License-Identifier: BSD-2-Clause + +use crate::wsprd_wrapper::WsprdWrapper; +use std::fs; +use std::io::Write; +use std::path::PathBuf; + +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 { + wsprd: WsprdWrapper, +} + +impl WsprDecoder { + pub fn new() -> Result { + let wsprd = WsprdWrapper::default_binary(); + if !wsprd.is_available() { + return Err("wsprd not found in PATH".to_string()); + } + Ok(Self { wsprd }) + } + + 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 = write_temp_wav(samples)?; + let output = self.wsprd.decode_wav(&wav_path); + let _ = fs::remove_file(&wav_path); + let stdout = output?; + Ok(parse_wsprd_output(&stdout, base_freq_hz)) + } +} + +fn write_temp_wav(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())?; + file.write_all(&1u16.to_le_bytes()) + .map_err(|e| e.to_string())?; + file.write_all(&1u16.to_le_bytes()) + .map_err(|e| e.to_string())?; + 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())?; + file.write_all(&16u16.to_le_bytes()) + .map_err(|e| e.to_string())?; + 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); + } +} diff --git a/src/trx-wspr/src/lib.rs b/src/trx-wspr/src/lib.rs index 2283fbe..cb903bf 100644 --- a/src/trx-wspr/src/lib.rs +++ b/src/trx-wspr/src/lib.rs @@ -2,172 +2,8 @@ // // SPDX-License-Identifier: BSD-2-Clause -use std::fs; -use std::io::Write; -use std::path::PathBuf; -use std::process::Command; +mod decoder; +mod wsprd_wrapper; -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); - } -} +pub use decoder::{WsprDecodeResult, WsprDecoder}; +pub use wsprd_wrapper::WsprdWrapper; diff --git a/src/trx-wspr/src/wsprd_wrapper.rs b/src/trx-wspr/src/wsprd_wrapper.rs new file mode 100644 index 0000000..ad9979b --- /dev/null +++ b/src/trx-wspr/src/wsprd_wrapper.rs @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: 2026 Stanislaw Grams +// +// SPDX-License-Identifier: BSD-2-Clause + +use std::path::Path; +use std::process::Command; + +#[derive(Debug, Clone)] +pub struct WsprdWrapper { + binary: String, +} + +impl WsprdWrapper { + pub fn new(binary: impl Into) -> Self { + Self { + binary: binary.into(), + } + } + + pub fn default_binary() -> Self { + Self::new("wsprd") + } + + pub fn is_available(&self) -> bool { + Command::new(&self.binary).output().is_ok() + } + + pub fn decode_wav(&self, wav_path: &Path) -> Result { + let output = Command::new(&self.binary) + .arg(wav_path) + .output() + .map_err(|e| format!("failed to run {}: {}", self.binary, e))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!( + "{} failed with status {}: {}", + self.binary, + output.status, + stderr.trim() + )); + } + Ok(String::from_utf8_lossy(&output.stdout).to_string()) + } +}