[refactor](trx-wspr): remove external wsprd integration
Drop all external wsprd wrapper/build plumbing and keep trx-wspr on an internal Rust-only decoder path. This removes wsprd process dependencies and leaves a native decoder scaffold with the same public API for incremental algorithm work. Co-authored-by: Codex <codex@openai.com> Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
This commit is contained in:
+28
-110
@@ -2,11 +2,6 @@
|
||||
//
|
||||
// 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;
|
||||
|
||||
@@ -19,16 +14,12 @@ pub struct WsprDecodeResult {
|
||||
}
|
||||
|
||||
pub struct WsprDecoder {
|
||||
wsprd: WsprdWrapper,
|
||||
min_rms: f32,
|
||||
}
|
||||
|
||||
impl WsprDecoder {
|
||||
pub fn new() -> Result<Self, String> {
|
||||
let wsprd = WsprdWrapper::default_binary();
|
||||
if !wsprd.is_available() {
|
||||
return Err("wsprd not found in PATH".to_string());
|
||||
}
|
||||
Ok(Self { wsprd })
|
||||
Ok(Self { min_rms: 0.0005 })
|
||||
}
|
||||
|
||||
pub fn sample_rate(&self) -> u32 {
|
||||
@@ -42,106 +33,30 @@ impl WsprDecoder {
|
||||
pub fn decode_slot(
|
||||
&self,
|
||||
samples: &[f32],
|
||||
base_freq_hz: Option<u64>,
|
||||
_base_freq_hz: Option<u64>,
|
||||
) -> Result<Vec<WsprDecodeResult>, 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))
|
||||
|
||||
// Native Rust implementation scaffold:
|
||||
// keep a strict "no decode on noise-only slots" gate while protocol/DSP
|
||||
// stages are implemented.
|
||||
let rms = slot_rms(&samples[..SLOT_SAMPLES]);
|
||||
if rms < self.min_rms {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
fn write_temp_wav(samples: &[f32]) -> Result<PathBuf, String> {
|
||||
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())?;
|
||||
fn slot_rms(samples: &[f32]) -> f32 {
|
||||
if samples.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn parse_wsprd_output(output: &str, base_freq_hz: Option<u64>) -> Vec<WsprDecodeResult> {
|
||||
output
|
||||
.lines()
|
||||
.filter_map(|line| parse_wsprd_line(line, base_freq_hz))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_wsprd_line(line: &str, base_freq_hz: Option<u64>) -> Option<WsprDecodeResult> {
|
||||
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::<Vec<_>>().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,
|
||||
})
|
||||
let sum_sq = samples.iter().map(|s| s * s).sum::<f32>();
|
||||
(sum_sq / samples.len() as f32).sqrt()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -149,12 +64,15 @@ 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);
|
||||
fn short_slot_returns_empty() {
|
||||
let dec = WsprDecoder::new().expect("decoder");
|
||||
let out = dec.decode_slot(&vec![0.0; dec.slot_samples() - 1], None);
|
||||
assert!(out.expect("decode").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rms_is_zero_for_silence() {
|
||||
let rms = slot_rms(&[0.0; 16]);
|
||||
assert_eq!(rms, 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,5 @@
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
mod decoder;
|
||||
mod wsprd_wrapper;
|
||||
|
||||
pub use decoder::{WsprDecodeResult, WsprDecoder};
|
||||
pub use wsprd_wrapper::WsprdWrapper;
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
//
|
||||
// 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<String>) -> 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<String, String> {
|
||||
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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user