[refactor](decoders): consolidate decoder crates under src/decoders/
Move trx-ft8 and trx-wspr into src/decoders/ alongside a new trx-aprs crate that extracts the Bell 202/AX.25 decoder from trx-server, giving all three modems a consistent crate-per-decoder layout. - src/decoders/trx-ft8/ (moved from src/trx-ft8/) - src/decoders/trx-wspr/ (moved from src/trx-wspr/) - src/decoders/trx-aprs/ (new — Bell 202 AFSK + AX.25/APRS decoder) - trx-ft8/build.rs: fix external/ft8_lib relative path after move - trx-server: drop decode::aprs module, use trx_aprs::AprsDecoder - AprsPacket stays in trx-core (mirrors Ft8Message / WsprMessage) - Workspace Cargo.toml updated with new member paths Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
# SPDX-FileCopyrightText: 2026 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
#
|
||||
# SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
[package]
|
||||
name = "trx-aprs"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
trx-core = { path = "../../trx-core" }
|
||||
@@ -0,0 +1,586 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
//! Bell 202 AFSK demodulator + AX.25/APRS decoder.
|
||||
//!
|
||||
//! Ported from the browser-side JavaScript implementation.
|
||||
|
||||
use trx_core::decode::AprsPacket;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CRC-16-CCITT
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CRC_CCITT_TABLE: [u16; 256] = {
|
||||
let mut table = [0u16; 256];
|
||||
let mut i = 0usize;
|
||||
while i < 256 {
|
||||
let mut crc = i as u16;
|
||||
let mut j = 0;
|
||||
while j < 8 {
|
||||
if crc & 1 != 0 {
|
||||
crc = (crc >> 1) ^ 0x8408;
|
||||
} else {
|
||||
crc >>= 1;
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
table[i] = crc;
|
||||
i += 1;
|
||||
}
|
||||
table
|
||||
};
|
||||
|
||||
fn crc16ccitt(bytes: &[u8]) -> u16 {
|
||||
let mut crc: u16 = 0xFFFF;
|
||||
for &b in bytes {
|
||||
crc = (crc >> 8) ^ CRC_CCITT_TABLE[((crc ^ b as u16) & 0xFF) as usize];
|
||||
}
|
||||
crc ^ 0xFFFF
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Correlation demodulator (one instance)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const BAUD: f32 = 1200.0;
|
||||
const MARK: f32 = 1200.0;
|
||||
const SPACE: f32 = 2200.0;
|
||||
const TWO_PI: f32 = std::f32::consts::TAU;
|
||||
const PLL_GAIN: f32 = 0.4;
|
||||
|
||||
struct Demodulator {
|
||||
samples_per_bit: f32,
|
||||
|
||||
// Energy gate
|
||||
energy_acc: f32,
|
||||
energy_count: usize,
|
||||
energy_window: usize,
|
||||
|
||||
// Oscillator phases
|
||||
mark_phase: f32,
|
||||
space_phase: f32,
|
||||
mark_phase_inc: f32,
|
||||
space_phase_inc: f32,
|
||||
|
||||
// Sliding-window correlation filter
|
||||
corr_len: usize,
|
||||
mark_i_buf: Vec<f32>,
|
||||
mark_q_buf: Vec<f32>,
|
||||
space_i_buf: Vec<f32>,
|
||||
space_q_buf: Vec<f32>,
|
||||
corr_idx: usize,
|
||||
mark_i_sum: f32,
|
||||
mark_q_sum: f32,
|
||||
space_i_sum: f32,
|
||||
space_q_sum: f32,
|
||||
|
||||
// Clock recovery
|
||||
last_bit: u8,
|
||||
bit_phase: f32,
|
||||
|
||||
// NRZI
|
||||
prev_sampled_bit: u8,
|
||||
|
||||
// HDLC
|
||||
ones: u32,
|
||||
frame_bits: Vec<u8>,
|
||||
in_frame: bool,
|
||||
|
||||
// Results
|
||||
frames: Vec<RawFrame>,
|
||||
}
|
||||
|
||||
struct RawFrame {
|
||||
payload: Vec<u8>,
|
||||
crc_ok: bool,
|
||||
}
|
||||
|
||||
impl Demodulator {
|
||||
fn new(sample_rate: u32, window_factor: f32) -> Self {
|
||||
let sr = sample_rate as f32;
|
||||
let samples_per_bit = sr / BAUD;
|
||||
let corr_len = (samples_per_bit * window_factor).round().max(2.0) as usize;
|
||||
let energy_window = (sr * 0.05).round() as usize;
|
||||
|
||||
Self {
|
||||
samples_per_bit,
|
||||
energy_acc: 0.0,
|
||||
energy_count: 0,
|
||||
energy_window,
|
||||
mark_phase: 0.0,
|
||||
space_phase: 0.0,
|
||||
mark_phase_inc: TWO_PI * MARK / sr,
|
||||
space_phase_inc: TWO_PI * SPACE / sr,
|
||||
corr_len,
|
||||
mark_i_buf: vec![0.0; corr_len],
|
||||
mark_q_buf: vec![0.0; corr_len],
|
||||
space_i_buf: vec![0.0; corr_len],
|
||||
space_q_buf: vec![0.0; corr_len],
|
||||
corr_idx: 0,
|
||||
mark_i_sum: 0.0,
|
||||
mark_q_sum: 0.0,
|
||||
space_i_sum: 0.0,
|
||||
space_q_sum: 0.0,
|
||||
last_bit: 0,
|
||||
bit_phase: 0.0,
|
||||
prev_sampled_bit: 0,
|
||||
ones: 0,
|
||||
frame_bits: Vec::new(),
|
||||
in_frame: false,
|
||||
frames: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn reset_state(&mut self) {
|
||||
self.mark_phase = 0.0;
|
||||
self.space_phase = 0.0;
|
||||
self.mark_i_buf.fill(0.0);
|
||||
self.mark_q_buf.fill(0.0);
|
||||
self.space_i_buf.fill(0.0);
|
||||
self.space_q_buf.fill(0.0);
|
||||
self.corr_idx = 0;
|
||||
self.mark_i_sum = 0.0;
|
||||
self.mark_q_sum = 0.0;
|
||||
self.space_i_sum = 0.0;
|
||||
self.space_q_sum = 0.0;
|
||||
self.last_bit = 0;
|
||||
self.bit_phase = 0.0;
|
||||
self.prev_sampled_bit = 0;
|
||||
self.ones = 0;
|
||||
self.frame_bits.clear();
|
||||
self.in_frame = false;
|
||||
}
|
||||
|
||||
fn process_buffer(&mut self, samples: &[f32]) -> Vec<RawFrame> {
|
||||
for &s in samples {
|
||||
self.process_sample(s);
|
||||
}
|
||||
std::mem::take(&mut self.frames)
|
||||
}
|
||||
|
||||
fn process_sample(&mut self, s: f32) {
|
||||
// Energy gate
|
||||
self.energy_acc += s * s;
|
||||
self.energy_count += 1;
|
||||
if self.energy_count >= self.energy_window {
|
||||
let rms = (self.energy_acc / self.energy_count as f32).sqrt();
|
||||
if rms < 0.001 {
|
||||
self.reset_state();
|
||||
}
|
||||
self.energy_acc = 0.0;
|
||||
self.energy_count = 0;
|
||||
}
|
||||
|
||||
// Mix with reference oscillators
|
||||
let m_i = s * self.mark_phase.cos();
|
||||
let m_q = s * self.mark_phase.sin();
|
||||
let s_i = s * self.space_phase.cos();
|
||||
let s_q = s * self.space_phase.sin();
|
||||
self.mark_phase += self.mark_phase_inc;
|
||||
self.space_phase += self.space_phase_inc;
|
||||
if self.mark_phase > TWO_PI {
|
||||
self.mark_phase -= TWO_PI;
|
||||
}
|
||||
if self.space_phase > TWO_PI {
|
||||
self.space_phase -= TWO_PI;
|
||||
}
|
||||
|
||||
// Sliding-window integration
|
||||
let idx = self.corr_idx;
|
||||
self.mark_i_sum += m_i - self.mark_i_buf[idx];
|
||||
self.mark_q_sum += m_q - self.mark_q_buf[idx];
|
||||
self.space_i_sum += s_i - self.space_i_buf[idx];
|
||||
self.space_q_sum += s_q - self.space_q_buf[idx];
|
||||
self.mark_i_buf[idx] = m_i;
|
||||
self.mark_q_buf[idx] = m_q;
|
||||
self.space_i_buf[idx] = s_i;
|
||||
self.space_q_buf[idx] = s_q;
|
||||
self.corr_idx = (idx + 1) % self.corr_len;
|
||||
|
||||
// Compare mark vs space energy
|
||||
let mark_energy = self.mark_i_sum * self.mark_i_sum + self.mark_q_sum * self.mark_q_sum;
|
||||
let space_energy =
|
||||
self.space_i_sum * self.space_i_sum + self.space_q_sum * self.space_q_sum;
|
||||
let bit: u8 = if mark_energy > space_energy { 1 } else { 0 };
|
||||
|
||||
// PLL clock recovery
|
||||
if bit != self.last_bit {
|
||||
self.last_bit = bit;
|
||||
let error = self.bit_phase - self.samples_per_bit / 2.0;
|
||||
self.bit_phase -= PLL_GAIN * error;
|
||||
}
|
||||
|
||||
self.bit_phase -= 1.0;
|
||||
if self.bit_phase <= 0.0 {
|
||||
self.bit_phase += self.samples_per_bit;
|
||||
self.process_bit(bit);
|
||||
}
|
||||
}
|
||||
|
||||
fn process_bit(&mut self, raw_bit: u8) {
|
||||
// NRZI decode: no transition = 1, transition = 0
|
||||
let decoded_bit: u8 = if raw_bit == self.prev_sampled_bit {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
self.prev_sampled_bit = raw_bit;
|
||||
|
||||
if decoded_bit == 1 {
|
||||
self.ones += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// decoded_bit == 0
|
||||
if self.ones >= 7 {
|
||||
// Abort
|
||||
self.in_frame = false;
|
||||
self.frame_bits.clear();
|
||||
self.ones = 0;
|
||||
return;
|
||||
}
|
||||
if self.ones == 6 {
|
||||
// Flag
|
||||
if self.in_frame && self.frame_bits.len() >= 136 {
|
||||
if let Some(frame) = self.bits_to_bytes() {
|
||||
self.frames.push(frame);
|
||||
}
|
||||
}
|
||||
self.frame_bits.clear();
|
||||
self.in_frame = true;
|
||||
self.ones = 0;
|
||||
return;
|
||||
}
|
||||
if self.ones == 5 {
|
||||
// Bit stuffing — flush 5 ones, discard stuffed zero
|
||||
if self.in_frame {
|
||||
for _ in 0..5 {
|
||||
self.frame_bits.push(1);
|
||||
}
|
||||
}
|
||||
self.ones = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal data
|
||||
if self.in_frame {
|
||||
for _ in 0..self.ones {
|
||||
self.frame_bits.push(1);
|
||||
}
|
||||
self.frame_bits.push(0);
|
||||
}
|
||||
self.ones = 0;
|
||||
}
|
||||
|
||||
fn bits_to_bytes(&self) -> Option<RawFrame> {
|
||||
let byte_len = self.frame_bits.len() / 8;
|
||||
if byte_len < 17 {
|
||||
return None;
|
||||
}
|
||||
let mut bytes = vec![0u8; byte_len];
|
||||
for (i, out) in bytes.iter_mut().enumerate() {
|
||||
let mut b: u8 = 0;
|
||||
for j in 0..8 {
|
||||
b |= self.frame_bits[i * 8 + j] << j;
|
||||
}
|
||||
*out = b;
|
||||
}
|
||||
|
||||
let payload = &bytes[..byte_len - 2];
|
||||
let fcs = bytes[byte_len - 2] as u16 | ((bytes[byte_len - 1] as u16) << 8);
|
||||
let computed = crc16ccitt(payload);
|
||||
let crc_ok = computed == fcs;
|
||||
|
||||
Some(RawFrame {
|
||||
payload: payload.to_vec(),
|
||||
crc_ok,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AX.25 address decoding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct Ax25Address {
|
||||
call: String,
|
||||
ssid: u8,
|
||||
last: bool,
|
||||
}
|
||||
|
||||
fn decode_ax25_address(bytes: &[u8], offset: usize) -> Ax25Address {
|
||||
let mut call = String::with_capacity(6);
|
||||
for i in 0..6 {
|
||||
let ch = bytes[offset + i] >> 1;
|
||||
if ch > 32 {
|
||||
call.push(ch as char);
|
||||
}
|
||||
}
|
||||
let call = call.trim_end().to_string();
|
||||
let ssid = (bytes[offset + 6] >> 1) & 0x0F;
|
||||
let last = (bytes[offset + 6] & 0x01) == 1;
|
||||
Ax25Address { call, ssid, last }
|
||||
}
|
||||
|
||||
struct Ax25Frame {
|
||||
src: Ax25Address,
|
||||
dest: Ax25Address,
|
||||
digis: Vec<Ax25Address>,
|
||||
info: Vec<u8>,
|
||||
}
|
||||
|
||||
fn parse_ax25(frame: &[u8]) -> Option<Ax25Frame> {
|
||||
if frame.len() < 16 {
|
||||
return None;
|
||||
}
|
||||
let dest = decode_ax25_address(frame, 0);
|
||||
let src = decode_ax25_address(frame, 7);
|
||||
|
||||
let mut offset = 14;
|
||||
let mut digis = Vec::new();
|
||||
let mut last_addr = src.last;
|
||||
while !last_addr && offset + 7 <= frame.len() {
|
||||
let digi = decode_ax25_address(frame, offset);
|
||||
last_addr = digi.last;
|
||||
digis.push(digi);
|
||||
offset += 7;
|
||||
}
|
||||
|
||||
if offset + 2 > frame.len() {
|
||||
return None;
|
||||
}
|
||||
// Skip control + PID bytes
|
||||
let info = frame[offset + 2..].to_vec();
|
||||
|
||||
Some(Ax25Frame {
|
||||
src,
|
||||
dest,
|
||||
digis,
|
||||
info,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// APRS parser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn format_call(addr: &Ax25Address) -> String {
|
||||
if addr.ssid != 0 {
|
||||
format!("{}-{}", addr.call, addr.ssid)
|
||||
} else {
|
||||
addr.call.clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_aprs(ax25: &Ax25Frame) -> AprsPacket {
|
||||
let src_call = format_call(&ax25.src);
|
||||
let dest_call = format_call(&ax25.dest);
|
||||
let path = ax25
|
||||
.digis
|
||||
.iter()
|
||||
.map(format_call)
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let info = &ax25.info;
|
||||
let info_str = String::from_utf8_lossy(info).to_string();
|
||||
|
||||
let packet_type = if !info.is_empty() {
|
||||
match info[0] {
|
||||
b'!' | b'=' | b'/' | b'@' => "Position",
|
||||
b':' => "Message",
|
||||
b'>' => "Status",
|
||||
b'T' => "Telemetry",
|
||||
b';' => "Object",
|
||||
b')' => "Item",
|
||||
b'`' | b'\'' => "Mic-E",
|
||||
_ => "Unknown",
|
||||
}
|
||||
} else {
|
||||
"Unknown"
|
||||
};
|
||||
|
||||
let mut lat = None;
|
||||
let mut lon = None;
|
||||
let mut symbol_table = None;
|
||||
let mut symbol_code = None;
|
||||
|
||||
if packet_type == "Position" {
|
||||
if let Some(pos) = parse_aprs_position(info) {
|
||||
lat = Some(pos.0);
|
||||
lon = Some(pos.1);
|
||||
symbol_table = Some(pos.2.to_string());
|
||||
symbol_code = Some(pos.3.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
AprsPacket {
|
||||
src_call,
|
||||
dest_call,
|
||||
path,
|
||||
info: info_str,
|
||||
info_bytes: info.to_vec(),
|
||||
packet_type: packet_type.to_string(),
|
||||
crc_ok: false, // set by caller
|
||||
lat,
|
||||
lon,
|
||||
symbol_table,
|
||||
symbol_code,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_aprs_position(info: &[u8]) -> Option<(f64, f64, char, char)> {
|
||||
if info.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let dt = info[0];
|
||||
|
||||
let pos = match dt {
|
||||
b'!' | b'=' => &info[1..],
|
||||
b'/' | b'@' => {
|
||||
if info.len() < 9 {
|
||||
return None;
|
||||
}
|
||||
&info[8..]
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
if pos.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if pos[0] < b'0' || pos[0] > b'9' {
|
||||
return parse_aprs_compressed(pos);
|
||||
}
|
||||
|
||||
// Uncompressed: DDMM.MMN/DDDMM.MMEsYYY
|
||||
if pos.len() < 19 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let sym_table = pos[8] as char;
|
||||
let sym_code = pos[18] as char;
|
||||
|
||||
let lat = parse_aprs_lat(&pos[..8])?;
|
||||
let lon = parse_aprs_lon(&pos[9..18])?;
|
||||
|
||||
Some((lat, lon, sym_table, sym_code))
|
||||
}
|
||||
|
||||
fn parse_aprs_compressed(pos: &[u8]) -> Option<(f64, f64, char, char)> {
|
||||
if pos.len() < 10 {
|
||||
return None;
|
||||
}
|
||||
let sym_table = pos[0] as char;
|
||||
|
||||
let mut lat_val: u32 = 0;
|
||||
let mut lon_val: u32 = 0;
|
||||
for i in 0..4 {
|
||||
let lc = pos[1 + i] as i32 - 33;
|
||||
let xc = pos[5 + i] as i32 - 33;
|
||||
if !(0..=90).contains(&lc) || !(0..=90).contains(&xc) {
|
||||
return None;
|
||||
}
|
||||
lat_val = lat_val * 91 + lc as u32;
|
||||
lon_val = lon_val * 91 + xc as u32;
|
||||
}
|
||||
|
||||
let lat = 90.0 - lat_val as f64 / 380926.0;
|
||||
let lon = -180.0 + lon_val as f64 / 190463.0;
|
||||
|
||||
if !(-90.0..=90.0).contains(&lat) || !(-180.0..=180.0).contains(&lon) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let sym_code = pos[9] as char;
|
||||
let lat = (lat * 1e6).round() / 1e6;
|
||||
let lon = (lon * 1e6).round() / 1e6;
|
||||
|
||||
Some((lat, lon, sym_table, sym_code))
|
||||
}
|
||||
|
||||
fn parse_aprs_lat(b: &[u8]) -> Option<f64> {
|
||||
if b.len() < 8 {
|
||||
return None;
|
||||
}
|
||||
let deg: f64 = std::str::from_utf8(&b[..2]).ok()?.parse().ok()?;
|
||||
let min: f64 = std::str::from_utf8(&b[2..7]).ok()?.parse().ok()?;
|
||||
let mut lat = deg + min / 60.0;
|
||||
match b[7] {
|
||||
b'S' | b's' => lat = -lat,
|
||||
b'N' | b'n' => {}
|
||||
_ => return None,
|
||||
}
|
||||
Some((lat * 1e6).round() / 1e6)
|
||||
}
|
||||
|
||||
fn parse_aprs_lon(b: &[u8]) -> Option<f64> {
|
||||
if b.len() < 9 {
|
||||
return None;
|
||||
}
|
||||
let deg: f64 = std::str::from_utf8(&b[..3]).ok()?.parse().ok()?;
|
||||
let min: f64 = std::str::from_utf8(&b[3..8]).ok()?.parse().ok()?;
|
||||
let mut lon = deg + min / 60.0;
|
||||
match b[8] {
|
||||
b'W' | b'w' => lon = -lon,
|
||||
b'E' | b'e' => {}
|
||||
_ => return None,
|
||||
}
|
||||
Some((lon * 1e6).round() / 1e6)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub struct AprsDecoder {
|
||||
demodulators: Vec<Demodulator>,
|
||||
}
|
||||
|
||||
impl AprsDecoder {
|
||||
pub fn new(sample_rate: u32) -> Self {
|
||||
Self {
|
||||
demodulators: vec![
|
||||
Demodulator::new(sample_rate, 1.0),
|
||||
Demodulator::new(sample_rate, 0.5),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn process_samples(&mut self, samples: &[f32]) -> Vec<AprsPacket> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut results = Vec::new();
|
||||
|
||||
for demod in &mut self.demodulators {
|
||||
for frame in demod.process_buffer(samples) {
|
||||
// Dedup by address prefix + payload length
|
||||
let key_len = frame.payload.len().min(14);
|
||||
let mut key = Vec::with_capacity(key_len + 4);
|
||||
key.extend_from_slice(&frame.payload[..key_len]);
|
||||
key.extend_from_slice(&(frame.payload.len() as u32).to_le_bytes());
|
||||
if !seen.insert(key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(ax25) = parse_ax25(&frame.payload) {
|
||||
let mut pkt = parse_aprs(&ax25);
|
||||
pkt.crc_ok = frame.crc_ok;
|
||||
results.push(pkt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
for demod in &mut self.demodulators {
|
||||
demod.reset_state();
|
||||
demod.energy_acc = 0.0;
|
||||
demod.energy_count = 0;
|
||||
demod.frames.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
# SPDX-FileCopyrightText: 2026 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
#
|
||||
# SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
[package]
|
||||
name = "trx-ft8"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[build-dependencies]
|
||||
cc = "1"
|
||||
@@ -0,0 +1,42 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
fn main() {
|
||||
let base = "../../../external/ft8_lib";
|
||||
let mut build = cc::Build::new();
|
||||
build
|
||||
.include(base)
|
||||
.include(format!("{base}/common"))
|
||||
.include(format!("{base}/fft"))
|
||||
.include(format!("{base}/ft8"))
|
||||
.define("_GNU_SOURCE", None)
|
||||
.define("_POSIX_C_SOURCE", "200809L")
|
||||
.file("src/ft8_wrapper.c")
|
||||
.file(format!("{base}/common/monitor.c"))
|
||||
.file(format!("{base}/fft/kiss_fft.c"))
|
||||
.file(format!("{base}/fft/kiss_fftr.c"))
|
||||
.file(format!("{base}/ft8/constants.c"))
|
||||
.file(format!("{base}/ft8/crc.c"))
|
||||
.file(format!("{base}/ft8/decode.c"))
|
||||
.file(format!("{base}/ft8/ldpc.c"))
|
||||
.file(format!("{base}/ft8/message.c"))
|
||||
.file(format!("{base}/ft8/text.c"))
|
||||
.flag_if_supported("-std=c99")
|
||||
.flag_if_supported("-Wno-unused-const-variable")
|
||||
.flag_if_supported("-Wno-unused-function")
|
||||
.compile("trx_ft8");
|
||||
|
||||
println!("cargo:rustc-link-lib=m");
|
||||
|
||||
println!("cargo:rerun-if-changed=src/ft8_wrapper.c");
|
||||
println!("cargo:rerun-if-changed={base}/common/monitor.c");
|
||||
println!("cargo:rerun-if-changed={base}/fft/kiss_fft.c");
|
||||
println!("cargo:rerun-if-changed={base}/fft/kiss_fftr.c");
|
||||
println!("cargo:rerun-if-changed={base}/ft8/constants.c");
|
||||
println!("cargo:rerun-if-changed={base}/ft8/crc.c");
|
||||
println!("cargo:rerun-if-changed={base}/ft8/decode.c");
|
||||
println!("cargo:rerun-if-changed={base}/ft8/ldpc.c");
|
||||
println!("cargo:rerun-if-changed={base}/ft8/message.c");
|
||||
println!("cargo:rerun-if-changed={base}/ft8/text.c");
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
#include <ft8/decode.h>
|
||||
#include <ft8/message.h>
|
||||
#include <ft8/text.h>
|
||||
#include <common/monitor.h>
|
||||
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
|
||||
// Callsign hash table (from demo/decode_ft8.c)
|
||||
#define CALLSIGN_HASHTABLE_SIZE 256
|
||||
|
||||
typedef struct
|
||||
{
|
||||
uint32_t hash;
|
||||
char callsign[12];
|
||||
} callsign_hashtable_entry_t;
|
||||
|
||||
static callsign_hashtable_entry_t callsign_hashtable[CALLSIGN_HASHTABLE_SIZE];
|
||||
static int callsign_hashtable_size = 0;
|
||||
|
||||
static void hashtable_init(void)
|
||||
{
|
||||
callsign_hashtable_size = 0;
|
||||
memset(callsign_hashtable, 0, sizeof(callsign_hashtable));
|
||||
}
|
||||
|
||||
static void hashtable_cleanup(uint8_t max_age)
|
||||
{
|
||||
for (int idx_hash = 0; idx_hash < CALLSIGN_HASHTABLE_SIZE; ++idx_hash)
|
||||
{
|
||||
if (callsign_hashtable[idx_hash].callsign[0] != '\0')
|
||||
{
|
||||
uint8_t age = (uint8_t)(callsign_hashtable[idx_hash].hash >> 24);
|
||||
if (age >= max_age)
|
||||
{
|
||||
callsign_hashtable[idx_hash].callsign[0] = '\0';
|
||||
callsign_hashtable[idx_hash].hash = 0;
|
||||
callsign_hashtable_size--;
|
||||
}
|
||||
else
|
||||
{
|
||||
callsign_hashtable[idx_hash].hash = (((uint32_t)age + 1u) << 24) | (callsign_hashtable[idx_hash].hash & 0x3FFFFFu);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void hashtable_add(const char* callsign, uint32_t hash)
|
||||
{
|
||||
int idx_hash = hash % CALLSIGN_HASHTABLE_SIZE;
|
||||
while (callsign_hashtable[idx_hash].callsign[0] != '\0')
|
||||
{
|
||||
if (((callsign_hashtable[idx_hash].hash & 0x3FFFFFu) == hash) && (0 == strcmp(callsign_hashtable[idx_hash].callsign, callsign)))
|
||||
{
|
||||
callsign_hashtable[idx_hash].hash &= 0x3FFFFFu;
|
||||
return;
|
||||
}
|
||||
idx_hash = (idx_hash + 1) % CALLSIGN_HASHTABLE_SIZE;
|
||||
}
|
||||
callsign_hashtable_size++;
|
||||
strncpy(callsign_hashtable[idx_hash].callsign, callsign, 11);
|
||||
callsign_hashtable[idx_hash].callsign[11] = '\0';
|
||||
callsign_hashtable[idx_hash].hash = hash;
|
||||
}
|
||||
|
||||
static bool hashtable_lookup(ftx_callsign_hash_type_t hash_type, uint32_t hash, char* callsign)
|
||||
{
|
||||
int hash_shift = (hash_type == FTX_CALLSIGN_HASH_22_BITS) ? 0 : (hash_type == FTX_CALLSIGN_HASH_12_BITS) ? 10 : 12;
|
||||
uint32_t mask = (hash_type == FTX_CALLSIGN_HASH_22_BITS) ? 0x3FFFFFu : (hash_type == FTX_CALLSIGN_HASH_12_BITS) ? 0xFFFu : 0x3FFu;
|
||||
|
||||
int idx_hash = hash % CALLSIGN_HASHTABLE_SIZE;
|
||||
while (callsign_hashtable[idx_hash].callsign[0] != '\0')
|
||||
{
|
||||
if (((callsign_hashtable[idx_hash].hash & 0x3FFFFFu) >> hash_shift) == (hash & mask))
|
||||
{
|
||||
strcpy(callsign, callsign_hashtable[idx_hash].callsign);
|
||||
return true;
|
||||
}
|
||||
idx_hash = (idx_hash + 1) % CALLSIGN_HASHTABLE_SIZE;
|
||||
}
|
||||
callsign[0] = '\0';
|
||||
return false;
|
||||
}
|
||||
|
||||
static ftx_callsign_hash_interface_t hash_if = {
|
||||
.lookup_hash = hashtable_lookup,
|
||||
.save_hash = hashtable_add,
|
||||
};
|
||||
|
||||
// Decoder wrapper
|
||||
|
||||
typedef struct
|
||||
{
|
||||
monitor_t mon;
|
||||
monitor_config_t cfg;
|
||||
} ft8_decoder_t;
|
||||
|
||||
typedef struct
|
||||
{
|
||||
char text[FTX_MAX_MESSAGE_LENGTH];
|
||||
float snr_db;
|
||||
float dt_s;
|
||||
float freq_hz;
|
||||
} ft8_decode_result_t;
|
||||
|
||||
ft8_decoder_t* ft8_decoder_create(int sample_rate, float f_min, float f_max, int time_osr, int freq_osr)
|
||||
{
|
||||
ft8_decoder_t* dec = (ft8_decoder_t*)calloc(1, sizeof(ft8_decoder_t));
|
||||
if (!dec)
|
||||
{
|
||||
return NULL;
|
||||
}
|
||||
dec->cfg.f_min = f_min;
|
||||
dec->cfg.f_max = f_max;
|
||||
dec->cfg.sample_rate = sample_rate;
|
||||
dec->cfg.time_osr = time_osr;
|
||||
dec->cfg.freq_osr = freq_osr;
|
||||
dec->cfg.protocol = FTX_PROTOCOL_FT8;
|
||||
|
||||
hashtable_init();
|
||||
monitor_init(&dec->mon, &dec->cfg);
|
||||
return dec;
|
||||
}
|
||||
|
||||
void ft8_decoder_free(ft8_decoder_t* dec)
|
||||
{
|
||||
if (!dec)
|
||||
return;
|
||||
monitor_free(&dec->mon);
|
||||
free(dec);
|
||||
}
|
||||
|
||||
int ft8_decoder_block_size(const ft8_decoder_t* dec)
|
||||
{
|
||||
return dec ? dec->mon.block_size : 0;
|
||||
}
|
||||
|
||||
void ft8_decoder_reset(ft8_decoder_t* dec)
|
||||
{
|
||||
if (!dec)
|
||||
return;
|
||||
monitor_reset(&dec->mon);
|
||||
}
|
||||
|
||||
void ft8_decoder_process(ft8_decoder_t* dec, const float* frame)
|
||||
{
|
||||
if (!dec || !frame)
|
||||
return;
|
||||
monitor_process(&dec->mon, frame);
|
||||
}
|
||||
|
||||
int ft8_decoder_is_ready(const ft8_decoder_t* dec)
|
||||
{
|
||||
if (!dec)
|
||||
return 0;
|
||||
return (dec->mon.wf.num_blocks >= dec->mon.wf.max_blocks) ? 1 : 0;
|
||||
}
|
||||
|
||||
int ft8_decoder_decode(ft8_decoder_t* dec, ft8_decode_result_t* out, int max_results)
|
||||
{
|
||||
if (!dec || !out || max_results <= 0)
|
||||
return 0;
|
||||
|
||||
const ftx_waterfall_t* wf = &dec->mon.wf;
|
||||
const int kMaxCandidates = 200;
|
||||
const int kMinScore = 10;
|
||||
const int kLdpcIters = 30;
|
||||
|
||||
ftx_candidate_t candidate_list[kMaxCandidates];
|
||||
int num_candidates = ftx_find_candidates(wf, kMaxCandidates, candidate_list, kMinScore);
|
||||
|
||||
int num_decoded = 0;
|
||||
ftx_message_t decoded[200];
|
||||
ftx_message_t* decoded_hashtable[200];
|
||||
for (int i = 0; i < 200; ++i)
|
||||
{
|
||||
decoded_hashtable[i] = NULL;
|
||||
}
|
||||
|
||||
for (int idx = 0; idx < num_candidates && num_decoded < max_results; ++idx)
|
||||
{
|
||||
const ftx_candidate_t* cand = &candidate_list[idx];
|
||||
|
||||
float freq_hz = (dec->mon.min_bin + cand->freq_offset + (float)cand->freq_sub / wf->freq_osr) / dec->mon.symbol_period;
|
||||
float time_sec = (cand->time_offset + (float)cand->time_sub / wf->time_osr) * dec->mon.symbol_period;
|
||||
|
||||
ftx_message_t message;
|
||||
ftx_decode_status_t status;
|
||||
if (!ftx_decode_candidate(wf, cand, kLdpcIters, &message, &status))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
int idx_hash = message.hash % 200;
|
||||
bool found_empty_slot = false;
|
||||
bool found_duplicate = false;
|
||||
do
|
||||
{
|
||||
if (decoded_hashtable[idx_hash] == NULL)
|
||||
{
|
||||
found_empty_slot = true;
|
||||
}
|
||||
else if ((decoded_hashtable[idx_hash]->hash == message.hash) && (0 == memcmp(decoded_hashtable[idx_hash]->payload, message.payload, sizeof(message.payload))))
|
||||
{
|
||||
found_duplicate = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
idx_hash = (idx_hash + 1) % 200;
|
||||
}
|
||||
} while (!found_empty_slot && !found_duplicate);
|
||||
|
||||
if (!found_empty_slot)
|
||||
continue;
|
||||
|
||||
memcpy(&decoded[idx_hash], &message, sizeof(message));
|
||||
decoded_hashtable[idx_hash] = &decoded[idx_hash];
|
||||
|
||||
char text[FTX_MAX_MESSAGE_LENGTH];
|
||||
ftx_message_offsets_t offsets;
|
||||
ftx_message_rc_t unpack_status = ftx_message_decode(&message, &hash_if, text, &offsets);
|
||||
if (unpack_status != FTX_MESSAGE_RC_OK)
|
||||
{
|
||||
snprintf(text, sizeof(text), "Error [%d] while unpacking!", (int)unpack_status);
|
||||
}
|
||||
|
||||
ft8_decode_result_t* dst = &out[num_decoded];
|
||||
strncpy(dst->text, text, sizeof(dst->text) - 1);
|
||||
dst->text[sizeof(dst->text) - 1] = '\0';
|
||||
dst->dt_s = time_sec;
|
||||
dst->freq_hz = freq_hz;
|
||||
dst->snr_db = cand->score * 0.5f;
|
||||
|
||||
num_decoded++;
|
||||
}
|
||||
|
||||
hashtable_cleanup(10);
|
||||
return num_decoded;
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
use libc::{c_float, c_int, c_void};
|
||||
use std::ffi::CStr;
|
||||
use std::ptr::NonNull;
|
||||
|
||||
const F_MIN_HZ: f32 = 200.0;
|
||||
const F_MAX_HZ: f32 = 3000.0;
|
||||
const TIME_OSR: i32 = 2;
|
||||
const FREQ_OSR: i32 = 2;
|
||||
|
||||
const FTX_MAX_MESSAGE_LENGTH: usize = 35;
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
struct Ft8DecodeResultRaw {
|
||||
text: [libc::c_char; FTX_MAX_MESSAGE_LENGTH],
|
||||
snr_db: c_float,
|
||||
dt_s: c_float,
|
||||
freq_hz: c_float,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Ft8DecodeResult {
|
||||
pub text: String,
|
||||
pub snr_db: f32,
|
||||
pub dt_s: f32,
|
||||
pub freq_hz: f32,
|
||||
}
|
||||
|
||||
extern "C" {
|
||||
fn ft8_decoder_create(
|
||||
sample_rate: c_int,
|
||||
f_min: c_float,
|
||||
f_max: c_float,
|
||||
time_osr: c_int,
|
||||
freq_osr: c_int,
|
||||
) -> *mut c_void;
|
||||
fn ft8_decoder_free(dec: *mut c_void);
|
||||
fn ft8_decoder_block_size(dec: *const c_void) -> c_int;
|
||||
fn ft8_decoder_reset(dec: *mut c_void);
|
||||
fn ft8_decoder_process(dec: *mut c_void, frame: *const c_float);
|
||||
fn ft8_decoder_is_ready(dec: *const c_void) -> c_int;
|
||||
fn ft8_decoder_decode(
|
||||
dec: *mut c_void,
|
||||
out: *mut Ft8DecodeResultRaw,
|
||||
max_results: c_int,
|
||||
) -> c_int;
|
||||
}
|
||||
|
||||
pub struct Ft8Decoder {
|
||||
inner: NonNull<c_void>,
|
||||
block_size: usize,
|
||||
sample_rate: u32,
|
||||
}
|
||||
|
||||
// SAFETY: Ft8Decoder owns its C-side state and is not shared across threads.
|
||||
// It is only moved into a single task, so Send is safe.
|
||||
unsafe impl Send for Ft8Decoder {}
|
||||
|
||||
impl Ft8Decoder {
|
||||
pub fn new(sample_rate: u32) -> Result<Self, String> {
|
||||
unsafe {
|
||||
let ptr = ft8_decoder_create(
|
||||
sample_rate as c_int,
|
||||
F_MIN_HZ,
|
||||
F_MAX_HZ,
|
||||
TIME_OSR as c_int,
|
||||
FREQ_OSR as c_int,
|
||||
);
|
||||
let inner = NonNull::new(ptr).ok_or_else(|| "ft8_decoder_create failed".to_string())?;
|
||||
let block_size = ft8_decoder_block_size(inner.as_ptr()) as usize;
|
||||
if block_size == 0 {
|
||||
ft8_decoder_free(inner.as_ptr());
|
||||
return Err("invalid FT8 block size".to_string());
|
||||
}
|
||||
Ok(Self {
|
||||
inner,
|
||||
block_size,
|
||||
sample_rate,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn block_size(&self) -> usize {
|
||||
self.block_size
|
||||
}
|
||||
|
||||
pub fn sample_rate(&self) -> u32 {
|
||||
self.sample_rate
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
unsafe {
|
||||
ft8_decoder_reset(self.inner.as_ptr());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn process_block(&mut self, block: &[f32]) {
|
||||
if block.len() < self.block_size {
|
||||
return;
|
||||
}
|
||||
unsafe {
|
||||
ft8_decoder_process(self.inner.as_ptr(), block.as_ptr());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decode_if_ready(&mut self, max_results: usize) -> Vec<Ft8DecodeResult> {
|
||||
unsafe {
|
||||
if ft8_decoder_is_ready(self.inner.as_ptr()) == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut raw = vec![
|
||||
Ft8DecodeResultRaw {
|
||||
text: [0; FTX_MAX_MESSAGE_LENGTH],
|
||||
snr_db: 0.0,
|
||||
dt_s: 0.0,
|
||||
freq_hz: 0.0,
|
||||
};
|
||||
max_results
|
||||
];
|
||||
let count =
|
||||
ft8_decoder_decode(self.inner.as_ptr(), raw.as_mut_ptr(), max_results as c_int);
|
||||
let count = count.max(0) as usize;
|
||||
let mut out = Vec::with_capacity(count);
|
||||
for item in raw.into_iter().take(count) {
|
||||
let text = CStr::from_ptr(item.text.as_ptr())
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
out.push(Ft8DecodeResult {
|
||||
text,
|
||||
snr_db: item.snr_db,
|
||||
dt_s: item.dt_s,
|
||||
freq_hz: item.freq_hz,
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Ft8Decoder {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
ft8_decoder_free(self.inner.as_ptr());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
# SPDX-FileCopyrightText: 2026 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
#
|
||||
# SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
[package]
|
||||
name = "trx-wspr"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
@@ -0,0 +1,236 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
//
|
||||
// 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 {
|
||||
pub message: String,
|
||||
pub snr_db: f32,
|
||||
pub dt_s: f32,
|
||||
pub freq_hz: f32,
|
||||
}
|
||||
|
||||
pub struct WsprDecoder {
|
||||
min_rms: f32,
|
||||
}
|
||||
|
||||
impl WsprDecoder {
|
||||
pub fn new() -> Result<Self, String> {
|
||||
Ok(Self { min_rms: 0.0005 })
|
||||
}
|
||||
|
||||
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<u64>,
|
||||
) -> Result<Vec<WsprDecodeResult>, String> {
|
||||
if samples.len() < SLOT_SAMPLES {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let rms = slot_rms(&samples[..SLOT_SAMPLES]);
|
||||
if rms < self.min_rms {
|
||||
return 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<u8>,
|
||||
snr_db: f32,
|
||||
}
|
||||
|
||||
fn estimate_base_tone_hz(signal: &[f32]) -> Option<f32> {
|
||||
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;
|
||||
}
|
||||
let sum_sq = samples.iter().map(|s| s * s).sum::<f32>();
|
||||
(sum_sq / samples.len() as f32).sqrt()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
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);
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
mod decoder;
|
||||
mod protocol;
|
||||
|
||||
pub use decoder::{WsprDecodeResult, WsprDecoder};
|
||||
@@ -0,0 +1,17 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
//
|
||||
// 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<WsprProtocolMessage> {
|
||||
None
|
||||
}
|
||||
Reference in New Issue
Block a user