diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 0a9c833..9cd2e16 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -8,8 +8,9 @@ This document lists all currently supported configuration options for `trx-serve Configuration lookup order: 1. `--config ` 2. `./trx-server.toml` -3. `~/.config/trx-rs/server.toml` -4. `/etc/trx-rs/server.toml` +3. `~/.trx-server.toml` +4. `~/.config/trx-rs/server.toml` +5. `/etc/trx-rs/server.toml` ### `trx-client` Configuration lookup order: diff --git a/src/trx-server/src/config.rs b/src/trx-server/src/config.rs index 72574ea..bb393d2 100644 --- a/src/trx-server/src/config.rs +++ b/src/trx-server/src/config.rs @@ -7,8 +7,9 @@ //! Supports loading configuration from TOML files with the following search order: //! 1. Path specified via `--config` CLI argument //! 2. `./trx-server.toml` (current directory) -//! 3. `~/.config/trx-rs/server.toml` (XDG config) -//! 4. `/etc/trx-rs/server.toml` (system-wide) +//! 3. `~/.trx-server.toml` (legacy per-user path) +//! 4. `~/.config/trx-rs/server.toml` (XDG config) +//! 5. `/etc/trx-rs/server.toml` (system-wide) use std::net::IpAddr; use std::path::{Path, PathBuf}; @@ -317,8 +318,8 @@ impl ServerConfig { general: GeneralConfig { callsign: Some("N0CALL".to_string()), log_level: Some("info".to_string()), - latitude: None, - longitude: None, + latitude: Some(52.2297), + longitude: Some(21.0122), }, rig: RigConfig { model: Some("ft817".to_string()), @@ -438,6 +439,9 @@ impl ConfigFile for ServerConfig { fn default_search_paths() -> Vec { let mut paths = Vec::new(); paths.push(PathBuf::from("trx-server.toml")); + if let Some(home_dir) = dirs::home_dir() { + paths.push(home_dir.join(".trx-server.toml")); + } if let Some(config_dir) = dirs::config_dir() { paths.push(config_dir.join("trx-rs").join("server.toml")); } @@ -555,4 +559,12 @@ tokens = ["secret123"] config.audio.frame_duration_ms = 7; assert!(config.validate().is_err()); } + + #[test] + fn test_default_search_paths_include_legacy_home_file() { + let paths = ServerConfig::default_search_paths(); + if let Some(home_dir) = dirs::home_dir() { + assert!(paths.contains(&home_dir.join(".trx-server.toml"))); + } + } } diff --git a/src/trx-wspr/src/decoder.rs b/src/trx-wspr/src/decoder.rs index 08a0146..2750991 100644 --- a/src/trx-wspr/src/decoder.rs +++ b/src/trx-wspr/src/decoder.rs @@ -2,8 +2,21 @@ // // SPDX-License-Identifier: BSD-2-Clause +use crate::protocol; + const WSPR_SAMPLE_RATE: u32 = 12_000; const SLOT_SAMPLES: usize = 120 * WSPR_SAMPLE_RATE as usize; +const WSPR_SYMBOL_COUNT: usize = 162; +const WSPR_SYMBOL_SAMPLES: usize = 8192; +const WSPR_SIGNAL_SAMPLES: usize = WSPR_SYMBOL_COUNT * WSPR_SYMBOL_SAMPLES; +const EXPECTED_SIGNAL_START_SAMPLES: usize = WSPR_SAMPLE_RATE as usize; // 1s +const TONE_SPACING_HZ: f32 = WSPR_SAMPLE_RATE as f32 / WSPR_SYMBOL_SAMPLES as f32; // 1.46484375 + +// Coarse search range for base tone. This matches common WSPR audio passband. +const BASE_SEARCH_MIN_HZ: f32 = 1200.0; +const BASE_SEARCH_MAX_HZ: f32 = 1800.0; +const BASE_SEARCH_STEP_HZ: f32 = 4.0; +const COARSE_SYMBOLS: usize = 48; #[derive(Debug, Clone)] pub struct WsprDecodeResult { @@ -39,18 +52,142 @@ impl WsprDecoder { return Ok(Vec::new()); } - // 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()) + let start = EXPECTED_SIGNAL_START_SAMPLES; + if start + WSPR_SIGNAL_SAMPLES > samples.len() { + return Ok(Vec::new()); + } + let signal = &samples[start..start + WSPR_SIGNAL_SAMPLES]; + + let Some(base_hz) = estimate_base_tone_hz(signal) else { + return Ok(Vec::new()); + }; + let demod = demodulate_symbols(signal, base_hz); + let Some(decoded) = protocol::decode_symbols(&demod.symbols) else { + return Ok(Vec::new()); + }; + + Ok(vec![WsprDecodeResult { + message: decoded.message, + snr_db: demod.snr_db, + dt_s: 0.0, + freq_hz: base_hz, + }]) } } +#[derive(Debug, Clone)] +struct DemodOutput { + symbols: Vec, + snr_db: f32, +} + +fn estimate_base_tone_hz(signal: &[f32]) -> Option { + if signal.len() < WSPR_SYMBOL_SAMPLES * COARSE_SYMBOLS { + return None; + } + + let mut best_freq = BASE_SEARCH_MIN_HZ; + let mut best_score = f32::MIN; + let mut freq = BASE_SEARCH_MIN_HZ; + while freq <= BASE_SEARCH_MAX_HZ { + let score = coarse_score(signal, freq); + if score > best_score { + best_score = score; + best_freq = freq; + } + freq += BASE_SEARCH_STEP_HZ; + } + Some(best_freq) +} + +fn coarse_score(signal: &[f32], base_hz: f32) -> f32 { + let mut score = 0.0_f32; + for sym in 0..COARSE_SYMBOLS { + let off = sym * WSPR_SYMBOL_SAMPLES; + let frame = &signal[off..off + WSPR_SYMBOL_SAMPLES]; + let mut best = 0.0_f32; + for tone in 0..4 { + let hz = base_hz + tone as f32 * TONE_SPACING_HZ; + let p = goertzel_power(frame, hz, WSPR_SAMPLE_RATE as f32); + if p > best { + best = p; + } + } + score += best; + } + score +} + +fn demodulate_symbols(signal: &[f32], base_hz: f32) -> DemodOutput { + let mut symbols = Vec::with_capacity(WSPR_SYMBOL_COUNT); + let mut signal_sum = 0.0_f32; + let mut noise_sum = 0.0_f32; + + for sym in 0..WSPR_SYMBOL_COUNT { + let off = sym * WSPR_SYMBOL_SAMPLES; + let frame = &signal[off..off + WSPR_SYMBOL_SAMPLES]; + + let mut tone_power = [0.0_f32; 4]; + for (i, power) in tone_power.iter_mut().enumerate() { + let hz = base_hz + i as f32 * TONE_SPACING_HZ; + *power = goertzel_power(frame, hz, WSPR_SAMPLE_RATE as f32); + } + + let mut best_idx = 0_u8; + let mut best_pow = tone_power[0]; + for (idx, p) in tone_power.iter().enumerate().skip(1) { + if *p > best_pow { + best_pow = *p; + best_idx = idx as u8; + } + } + + symbols.push(best_idx); + signal_sum += best_pow; + + let noise_a = goertzel_power( + frame, + base_hz - 8.0 * TONE_SPACING_HZ, + WSPR_SAMPLE_RATE as f32, + ); + let noise_b = goertzel_power( + frame, + base_hz + 12.0 * TONE_SPACING_HZ, + WSPR_SAMPLE_RATE as f32, + ); + noise_sum += (noise_a + noise_b) * 0.5; + } + + let signal_avg = signal_sum / WSPR_SYMBOL_COUNT as f32; + let noise_avg = (noise_sum / WSPR_SYMBOL_COUNT as f32).max(1e-12); + let snr_db = 10.0 * (signal_avg / noise_avg).max(1e-12).log10(); + + DemodOutput { symbols, snr_db } +} + +fn goertzel_power(frame: &[f32], target_hz: f32, sample_rate: f32) -> f32 { + let n = frame.len() as f32; + let k = (0.5 + (n * target_hz / sample_rate)).floor(); + let w = 2.0 * std::f32::consts::PI * k / n; + let coeff = 2.0 * w.cos(); + + let mut s_prev = 0.0_f32; + let mut s_prev2 = 0.0_f32; + for (idx, &x) in frame.iter().enumerate() { + let win = 0.5_f32 - 0.5_f32 * (2.0_f32 * std::f32::consts::PI * idx as f32 / n).cos(); + let s = x * win + coeff * s_prev - s_prev2; + s_prev2 = s_prev; + s_prev = s; + } + + s_prev2 * s_prev2 + s_prev * s_prev - coeff * s_prev * s_prev2 +} + fn slot_rms(samples: &[f32]) -> f32 { if samples.is_empty() { return 0.0; @@ -75,4 +212,25 @@ mod tests { let rms = slot_rms(&[0.0; 16]); assert_eq!(rms, 0.0); } + + #[test] + fn base_search_finds_synthetic_signal() { + let mut slot = vec![0.0_f32; SLOT_SAMPLES]; + let base_hz = 1496.0_f32; + let start = EXPECTED_SIGNAL_START_SAMPLES; + + for sym in 0..WSPR_SYMBOL_COUNT { + let tone = (sym % 4) as f32; + let freq = base_hz + tone * TONE_SPACING_HZ; + let begin = start + sym * WSPR_SYMBOL_SAMPLES; + for i in 0..WSPR_SYMBOL_SAMPLES { + let t = i as f32 / WSPR_SAMPLE_RATE as f32; + slot[begin + i] = (2.0 * std::f32::consts::PI * freq * t).sin() * 0.2; + } + } + + let signal = &slot[start..start + WSPR_SIGNAL_SAMPLES]; + let estimated = estimate_base_tone_hz(signal).expect("base tone"); + assert!((estimated - base_hz).abs() <= BASE_SEARCH_STEP_HZ); + } } diff --git a/src/trx-wspr/src/lib.rs b/src/trx-wspr/src/lib.rs index c17ef5d..c8ea0d9 100644 --- a/src/trx-wspr/src/lib.rs +++ b/src/trx-wspr/src/lib.rs @@ -3,5 +3,6 @@ // SPDX-License-Identifier: BSD-2-Clause mod decoder; +mod protocol; pub use decoder::{WsprDecodeResult, WsprDecoder}; diff --git a/src/trx-wspr/src/protocol.rs b/src/trx-wspr/src/protocol.rs new file mode 100644 index 0000000..b4bda00 --- /dev/null +++ b/src/trx-wspr/src/protocol.rs @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: 2026 Stanislaw Grams +// +// SPDX-License-Identifier: BSD-2-Clause + +/// Decoded WSPR message payload. +#[derive(Debug, Clone)] +pub struct WsprProtocolMessage { + pub message: String, +} + +/// Attempt protocol-level decode from 162 4-FSK symbols. +/// +/// This boundary keeps DSP and protocol concerns separated while the +/// native Rust decoder is implemented incrementally. +pub fn decode_symbols(_symbols: &[u8]) -> Option { + None +} diff --git a/trx-server.toml.example b/trx-server.toml.example index 5e9b33c..967ee06 100644 --- a/trx-server.toml.example +++ b/trx-server.toml.example @@ -2,6 +2,7 @@ # # Copy this file to one of: # ./trx-server.toml (current directory) +# ~/.trx-server.toml (legacy user config) # ~/.config/trx-rs/server.toml (user config) # /etc/trx-rs/server.toml (system-wide) #