[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
|
// 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 WSPR_SAMPLE_RATE: u32 = 12_000;
|
||||||
const SLOT_SAMPLES: usize = 120 * WSPR_SAMPLE_RATE as usize;
|
const SLOT_SAMPLES: usize = 120 * WSPR_SAMPLE_RATE as usize;
|
||||||
|
|
||||||
@@ -19,16 +14,12 @@ pub struct WsprDecodeResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct WsprDecoder {
|
pub struct WsprDecoder {
|
||||||
wsprd: WsprdWrapper,
|
min_rms: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WsprDecoder {
|
impl WsprDecoder {
|
||||||
pub fn new() -> Result<Self, String> {
|
pub fn new() -> Result<Self, String> {
|
||||||
let wsprd = WsprdWrapper::default_binary();
|
Ok(Self { min_rms: 0.0005 })
|
||||||
if !wsprd.is_available() {
|
|
||||||
return Err("wsprd not found in PATH".to_string());
|
|
||||||
}
|
|
||||||
Ok(Self { wsprd })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn sample_rate(&self) -> u32 {
|
pub fn sample_rate(&self) -> u32 {
|
||||||
@@ -42,106 +33,30 @@ impl WsprDecoder {
|
|||||||
pub fn decode_slot(
|
pub fn decode_slot(
|
||||||
&self,
|
&self,
|
||||||
samples: &[f32],
|
samples: &[f32],
|
||||||
base_freq_hz: Option<u64>,
|
_base_freq_hz: Option<u64>,
|
||||||
) -> Result<Vec<WsprDecodeResult>, String> {
|
) -> Result<Vec<WsprDecodeResult>, String> {
|
||||||
if samples.len() < SLOT_SAMPLES {
|
if samples.len() < SLOT_SAMPLES {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
let wav_path = write_temp_wav(samples)?;
|
|
||||||
let output = self.wsprd.decode_wav(&wav_path);
|
// Native Rust implementation scaffold:
|
||||||
let _ = fs::remove_file(&wav_path);
|
// keep a strict "no decode on noise-only slots" gate while protocol/DSP
|
||||||
let stdout = output?;
|
// stages are implemented.
|
||||||
Ok(parse_wsprd_output(&stdout, base_freq_hz))
|
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> {
|
fn slot_rms(samples: &[f32]) -> f32 {
|
||||||
let mut path = std::env::temp_dir();
|
if samples.is_empty() {
|
||||||
let unique = format!(
|
return 0.0;
|
||||||
"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())?;
|
|
||||||
}
|
}
|
||||||
|
let sum_sq = samples.iter().map(|s| s * s).sum::<f32>();
|
||||||
Ok(path)
|
(sum_sq / samples.len() as f32).sqrt()
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -149,12 +64,15 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_line_basic() {
|
fn short_slot_returns_empty() {
|
||||||
let line = "0001 -24 0.3 14097100 -1 CQ TEST FN20 37";
|
let dec = WsprDecoder::new().expect("decoder");
|
||||||
let parsed = parse_wsprd_line(line, Some(14_097_000)).expect("parse");
|
let out = dec.decode_slot(&vec![0.0; dec.slot_samples() - 1], None);
|
||||||
assert_eq!(parsed.message, "CQ TEST FN20 37");
|
assert!(out.expect("decode").is_empty());
|
||||||
assert_eq!(parsed.snr_db, -24.0);
|
}
|
||||||
assert_eq!(parsed.dt_s, 0.3);
|
|
||||||
assert_eq!(parsed.freq_hz, 100.0);
|
#[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
|
// SPDX-License-Identifier: BSD-2-Clause
|
||||||
|
|
||||||
mod decoder;
|
mod decoder;
|
||||||
mod wsprd_wrapper;
|
|
||||||
|
|
||||||
pub use decoder::{WsprDecodeResult, WsprDecoder};
|
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