Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
[package]
|
||||
name = "trx-ais"
|
||||
version.workspace = true
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
trx-core = { path = "../../trx-core" }
|
||||
@@ -0,0 +1,472 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! Basic AIS GMSK/HDLC decoder.
|
||||
//!
|
||||
//! This decoder operates on narrowband FM-demodulated audio. It uses a simple
|
||||
//! sign slicer at the symbol rate, HDLC flag detection with NRZI decoding and
|
||||
//! bit de-stuffing, then parses common AIS position/static messages.
|
||||
|
||||
use trx_core::decode::AisMessage;
|
||||
|
||||
const AIS_BAUD: f32 = 9_600.0;
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct RawFrame {
|
||||
payload: Vec<u8>,
|
||||
bits: Vec<u8>,
|
||||
crc_ok: bool,
|
||||
}
|
||||
|
||||
/// AIS (Automatic Identification System) GMSK/HDLC decoder.
|
||||
///
|
||||
/// Operates on narrowband FM-demodulated audio at any sample rate (internally
|
||||
/// resampled to the 9,600 baud AIS symbol rate). The decoder performs sign
|
||||
/// slicing, NRZI decoding, HDLC flag detection with bit de-stuffing, CRC-16
|
||||
/// validation, and parsing of common AIS message types (1–3, 5, 18, 19).
|
||||
///
|
||||
/// # Usage
|
||||
///
|
||||
/// ```ignore
|
||||
/// let mut decoder = AisDecoder::new(48_000);
|
||||
/// let messages = decoder.process_samples(&pcm_samples, "A");
|
||||
/// ```
|
||||
///
|
||||
/// Call [`reset()`](Self::reset) when switching frequency or restarting
|
||||
/// reception to clear internal symbol-tracking state.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AisDecoder {
|
||||
sample_rate: f32,
|
||||
symbol_phase: f32,
|
||||
dc_state: f32,
|
||||
lp_state: f32,
|
||||
env_state: f32,
|
||||
prev_raw_bit: u8,
|
||||
ones: u32,
|
||||
in_frame: bool,
|
||||
frame_bits: Vec<u8>,
|
||||
frames: Vec<RawFrame>,
|
||||
}
|
||||
|
||||
impl AisDecoder {
|
||||
pub fn new(sample_rate: u32) -> Self {
|
||||
Self {
|
||||
sample_rate: sample_rate.max(1) as f32,
|
||||
symbol_phase: 0.0,
|
||||
dc_state: 0.0,
|
||||
lp_state: 0.0,
|
||||
env_state: 1e-3,
|
||||
prev_raw_bit: 0,
|
||||
ones: 0,
|
||||
in_frame: false,
|
||||
frame_bits: Vec::new(),
|
||||
frames: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
self.symbol_phase = 0.0;
|
||||
self.dc_state = 0.0;
|
||||
self.lp_state = 0.0;
|
||||
self.env_state = 1e-3;
|
||||
self.prev_raw_bit = 0;
|
||||
self.ones = 0;
|
||||
self.in_frame = false;
|
||||
self.frame_bits.clear();
|
||||
self.frames.clear();
|
||||
}
|
||||
|
||||
pub fn process_samples(&mut self, samples: &[f32], channel: &str) -> Vec<AisMessage> {
|
||||
for &sample in samples {
|
||||
self.process_sample(sample);
|
||||
}
|
||||
|
||||
let frames = std::mem::take(&mut self.frames);
|
||||
let mut out = Vec::new();
|
||||
for frame in frames {
|
||||
if let Some(msg) = parse_frame(frame, channel) {
|
||||
out.push(msg);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn process_sample(&mut self, sample: f32) {
|
||||
// Remove slow DC drift from the FM discriminator output.
|
||||
self.dc_state += 0.0025 * (sample - self.dc_state);
|
||||
let dc_free = sample - self.dc_state;
|
||||
|
||||
// Gentle low-pass smoothing to suppress narrow impulsive noise.
|
||||
self.lp_state += 0.28 * (dc_free - self.lp_state);
|
||||
|
||||
// Track envelope to keep the slicer stable on weak signals.
|
||||
self.env_state += 0.02 * (self.lp_state.abs() - self.env_state);
|
||||
let normalized = if self.env_state > 1e-4 {
|
||||
self.lp_state / self.env_state
|
||||
} else {
|
||||
self.lp_state
|
||||
};
|
||||
|
||||
self.symbol_phase += AIS_BAUD;
|
||||
while self.symbol_phase >= self.sample_rate {
|
||||
self.symbol_phase -= self.sample_rate;
|
||||
let raw_bit = if normalized >= 0.0 { 1 } else { 0 };
|
||||
self.process_symbol(raw_bit);
|
||||
}
|
||||
}
|
||||
|
||||
fn process_symbol(&mut self, raw_bit: u8) {
|
||||
let decoded_bit = if raw_bit == self.prev_raw_bit { 1 } else { 0 };
|
||||
self.prev_raw_bit = raw_bit;
|
||||
|
||||
if decoded_bit == 1 {
|
||||
self.ones += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// A zero terminates the current run of ones.
|
||||
if self.ones >= 7 {
|
||||
self.in_frame = false;
|
||||
self.frame_bits.clear();
|
||||
self.ones = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if self.ones == 6 {
|
||||
if self.in_frame {
|
||||
if let Some(frame) = self.bits_to_frame() {
|
||||
self.frames.push(frame);
|
||||
}
|
||||
}
|
||||
self.frame_bits.clear();
|
||||
self.in_frame = true;
|
||||
self.ones = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if self.ones == 5 {
|
||||
if self.in_frame {
|
||||
for _ in 0..5 {
|
||||
self.frame_bits.push(1);
|
||||
}
|
||||
}
|
||||
self.ones = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
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_frame(&self) -> Option<RawFrame> {
|
||||
if self.frame_bits.len() < 24 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let usable_bits = self.frame_bits.len() - (self.frame_bits.len() % 8);
|
||||
if usable_bits < 24 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let bits = self.frame_bits[..usable_bits].to_vec();
|
||||
let mut bytes = Vec::with_capacity(usable_bits / 8);
|
||||
for chunk in bits.chunks(8) {
|
||||
let mut byte = 0u8;
|
||||
for (idx, &bit) in chunk.iter().enumerate() {
|
||||
if bit != 0 {
|
||||
byte |= 1 << idx;
|
||||
}
|
||||
}
|
||||
bytes.push(byte);
|
||||
}
|
||||
|
||||
if bytes.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let payload_len = bytes.len() - 2;
|
||||
let payload = bytes[..payload_len].to_vec();
|
||||
let received_fcs = u16::from_le_bytes([bytes[payload_len], bytes[payload_len + 1]]);
|
||||
let crc_ok = crc16ccitt(&payload) == received_fcs;
|
||||
|
||||
Some(RawFrame {
|
||||
payload,
|
||||
bits,
|
||||
crc_ok,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_frame(frame: RawFrame, channel: &str) -> Option<AisMessage> {
|
||||
if !frame.crc_ok {
|
||||
return None;
|
||||
}
|
||||
|
||||
let bits = bytes_to_msb_bits(&frame.payload);
|
||||
if bits.len() < 40 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let message_type = get_uint(&bits, 0, 6)? as u8;
|
||||
let repeat = get_uint(&bits, 6, 2)? as u8;
|
||||
let mmsi = get_uint(&bits, 8, 30)? as u32;
|
||||
|
||||
let mut msg = AisMessage {
|
||||
rig_id: None,
|
||||
ts_ms: None,
|
||||
channel: channel.to_string(),
|
||||
message_type,
|
||||
repeat,
|
||||
mmsi,
|
||||
crc_ok: frame.crc_ok,
|
||||
bit_len: frame.bits.len(),
|
||||
raw_bytes: frame.payload,
|
||||
lat: None,
|
||||
lon: None,
|
||||
sog_knots: None,
|
||||
cog_deg: None,
|
||||
heading_deg: None,
|
||||
nav_status: None,
|
||||
vessel_name: None,
|
||||
callsign: None,
|
||||
destination: None,
|
||||
};
|
||||
|
||||
match message_type {
|
||||
1..=3 => {
|
||||
msg.nav_status = get_uint(&bits, 38, 4).map(|v| v as u8);
|
||||
msg.sog_knots = decode_tenths(get_uint(&bits, 50, 10)?, 1023);
|
||||
msg.lon = decode_coord(get_int(&bits, 61, 28)?, 181.0);
|
||||
msg.lat = decode_coord(get_int(&bits, 89, 27)?, 91.0);
|
||||
msg.cog_deg = decode_tenths(get_uint(&bits, 116, 12)?, 3600);
|
||||
msg.heading_deg = decode_heading(get_uint(&bits, 128, 9)?);
|
||||
}
|
||||
18 => {
|
||||
msg.sog_knots = decode_tenths(get_uint(&bits, 46, 10)?, 1023);
|
||||
msg.lon = decode_coord(get_int(&bits, 57, 28)?, 181.0);
|
||||
msg.lat = decode_coord(get_int(&bits, 85, 27)?, 91.0);
|
||||
msg.cog_deg = decode_tenths(get_uint(&bits, 112, 12)?, 3600);
|
||||
msg.heading_deg = decode_heading(get_uint(&bits, 124, 9)?);
|
||||
}
|
||||
19 => {
|
||||
msg.sog_knots = decode_tenths(get_uint(&bits, 46, 10)?, 1023);
|
||||
msg.lon = decode_coord(get_int(&bits, 57, 28)?, 181.0);
|
||||
msg.lat = decode_coord(get_int(&bits, 85, 27)?, 91.0);
|
||||
msg.cog_deg = decode_tenths(get_uint(&bits, 112, 12)?, 3600);
|
||||
msg.heading_deg = decode_heading(get_uint(&bits, 124, 9)?);
|
||||
msg.vessel_name = decode_sixbit_text(&bits, 143, 120);
|
||||
}
|
||||
5 => {
|
||||
msg.callsign = decode_sixbit_text(&bits, 70, 42);
|
||||
msg.vessel_name = decode_sixbit_text(&bits, 112, 120);
|
||||
msg.destination = decode_sixbit_text(&bits, 302, 120);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Some(msg)
|
||||
}
|
||||
|
||||
fn bytes_to_msb_bits(bytes: &[u8]) -> Vec<u8> {
|
||||
let mut bits = Vec::with_capacity(bytes.len() * 8);
|
||||
for &byte in bytes {
|
||||
for shift in (0..8).rev() {
|
||||
bits.push((byte >> shift) & 1);
|
||||
}
|
||||
}
|
||||
bits
|
||||
}
|
||||
|
||||
fn get_uint(bits: &[u8], start: usize, len: usize) -> Option<u32> {
|
||||
if len == 0 || start.checked_add(len)? > bits.len() || len > 32 {
|
||||
return None;
|
||||
}
|
||||
let mut out = 0u32;
|
||||
for &bit in &bits[start..start + len] {
|
||||
out = (out << 1) | u32::from(bit);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn get_int(bits: &[u8], start: usize, len: usize) -> Option<i32> {
|
||||
let raw = get_uint(bits, start, len)?;
|
||||
if len == 0 || len > 31 {
|
||||
return None;
|
||||
}
|
||||
let sign_mask = 1u32 << (len - 1);
|
||||
if raw & sign_mask == 0 {
|
||||
Some(raw as i32)
|
||||
} else {
|
||||
Some((raw as i32) - ((1u32 << len) as i32))
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_tenths(raw: u32, invalid: u32) -> Option<f32> {
|
||||
if raw == invalid {
|
||||
None
|
||||
} else {
|
||||
Some(raw as f32 / 10.0)
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_heading(raw: u32) -> Option<u16> {
|
||||
if raw >= 360 {
|
||||
None
|
||||
} else {
|
||||
Some(raw as u16)
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_coord(raw: i32, invalid_abs: f64) -> Option<f64> {
|
||||
let value = raw as f64 / 600_000.0;
|
||||
if value.abs() >= invalid_abs {
|
||||
None
|
||||
} else {
|
||||
Some(value)
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_sixbit_text(bits: &[u8], start: usize, len: usize) -> Option<String> {
|
||||
if start.checked_add(len)? > bits.len() || !len.is_multiple_of(6) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut out = String::new();
|
||||
for offset in (0..len).step_by(6) {
|
||||
let value = get_uint(bits, start + offset, 6)? as u8;
|
||||
let ch = if value < 32 {
|
||||
char::from(value + 64)
|
||||
} else {
|
||||
char::from(value)
|
||||
};
|
||||
if ch != '@' {
|
||||
out.push(ch);
|
||||
}
|
||||
}
|
||||
|
||||
let trimmed = out.trim().trim_matches('@').trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn payload_with_crc(payload: &[u8]) -> Vec<u8> {
|
||||
let mut out = payload.to_vec();
|
||||
out.extend_from_slice(&crc16ccitt(payload).to_le_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
fn bytes_to_lsb_bits(bytes: &[u8]) -> Vec<u8> {
|
||||
let mut bits = Vec::with_capacity(bytes.len() * 8);
|
||||
for &byte in bytes {
|
||||
for shift in 0..8 {
|
||||
bits.push((byte >> shift) & 1);
|
||||
}
|
||||
}
|
||||
bits
|
||||
}
|
||||
|
||||
fn bitstuff(bits: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(bits.len() + bits.len() / 5);
|
||||
let mut ones = 0u32;
|
||||
for &bit in bits {
|
||||
out.push(bit);
|
||||
if bit == 1 {
|
||||
ones += 1;
|
||||
if ones == 5 {
|
||||
out.push(0);
|
||||
ones = 0;
|
||||
}
|
||||
} else {
|
||||
ones = 0;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn nrzi_encode(bits: &[u8]) -> Vec<u8> {
|
||||
let mut state = 0u8;
|
||||
let mut out = Vec::with_capacity(bits.len());
|
||||
for &bit in bits {
|
||||
if bit == 0 {
|
||||
state ^= 1;
|
||||
}
|
||||
out.push(state);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_signed_coordinates() {
|
||||
assert_eq!(decode_coord(60_000, 181.0), Some(0.1));
|
||||
assert_eq!(decode_coord(-60_000, 181.0), Some(-0.1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_sixbit_name() {
|
||||
let bytes = [0x10_u8, 0x41_u8, 0x11_u8, 0x92_u8, 0x08_u8, 0x00_u8];
|
||||
let bits = bytes_to_msb_bits(&bytes);
|
||||
let text = decode_sixbit_text(&bits, 0, 36);
|
||||
assert!(text.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovers_hdlc_frame_from_raw_nrzi_bits() {
|
||||
let payload = [0x11_u8, 0x22_u8, 0x7E_u8, 0x00_u8, 0xF0_u8];
|
||||
let frame_bytes = payload_with_crc(&payload);
|
||||
let mut hdlc_bits = bytes_to_lsb_bits(&[0x7E]);
|
||||
hdlc_bits.extend(bitstuff(&bytes_to_lsb_bits(&frame_bytes)));
|
||||
hdlc_bits.extend(bytes_to_lsb_bits(&[0x7E]));
|
||||
let raw_bits = nrzi_encode(&hdlc_bits);
|
||||
|
||||
let mut decoder = AisDecoder::new(48_000);
|
||||
for raw_bit in raw_bits {
|
||||
decoder.process_symbol(raw_bit);
|
||||
}
|
||||
|
||||
assert_eq!(decoder.frames.len(), 1);
|
||||
let frame = &decoder.frames[0];
|
||||
assert!(frame.crc_ok);
|
||||
assert_eq!(frame.payload, payload);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
[package]
|
||||
name = "trx-aprs"
|
||||
version.workspace = true
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
trx-core = { path = "../../trx-core" }
|
||||
@@ -0,0 +1,923 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! 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 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, baud: f32, mark_hz: f32, space_hz: f32, 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_hz / sr,
|
||||
space_phase_inc: TWO_PI * space_hz / 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 {
|
||||
rig_id: None,
|
||||
ts_ms: None,
|
||||
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 {
|
||||
/// VHF APRS: Bell 202, 1200 baud, mark=1200 Hz, space=2200 Hz.
|
||||
pub fn new(sample_rate: u32) -> Self {
|
||||
Self {
|
||||
demodulators: vec![
|
||||
Demodulator::new(sample_rate, 1200.0, 1200.0, 2200.0, 1.0),
|
||||
Demodulator::new(sample_rate, 1200.0, 1200.0, 2200.0, 0.5),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/// HF APRS: 300 baud, mark=1600 Hz, space=1800 Hz (200 Hz shift).
|
||||
pub fn new_hf(sample_rate: u32) -> Self {
|
||||
Self {
|
||||
demodulators: vec![
|
||||
Demodulator::new(sample_rate, 300.0, 1600.0, 1800.0, 1.0),
|
||||
Demodulator::new(sample_rate, 300.0, 1600.0, 1800.0, 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ======================================================================
|
||||
// CRC-16-CCITT
|
||||
// ======================================================================
|
||||
|
||||
#[test]
|
||||
fn crc16_empty() {
|
||||
// CRC of empty input = 0xFFFF ^ 0xFFFF = 0x0000
|
||||
assert_eq!(crc16ccitt(&[]), 0x0000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crc16_known_vector() {
|
||||
// "123456789" has well-known CCITT (x25) CRC = 0x906E
|
||||
assert_eq!(crc16ccitt(b"123456789"), 0x906E);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crc16_frame_with_appended_fcs_is_zero() {
|
||||
// When the FCS is appended to the payload, the CRC of the whole
|
||||
// sequence should yield the residue constant 0x0F47.
|
||||
let payload = b"123456789";
|
||||
let fcs = crc16ccitt(payload);
|
||||
let mut with_fcs = payload.to_vec();
|
||||
with_fcs.push(fcs as u8);
|
||||
with_fcs.push((fcs >> 8) as u8);
|
||||
assert_eq!(crc16ccitt(&with_fcs), 0x0F47);
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// AX.25 address decoding
|
||||
// ======================================================================
|
||||
|
||||
#[test]
|
||||
fn decode_ax25_address_basic() {
|
||||
// AX.25 addresses are left-shifted by 1 bit. "N0CALL" → bytes shifted.
|
||||
let mut addr = [0u8; 7];
|
||||
for (i, &ch) in b"N0CALL".iter().enumerate() {
|
||||
addr[i] = ch << 1;
|
||||
}
|
||||
addr[6] = (0 << 1) | 1; // SSID=0, last=true
|
||||
|
||||
let decoded = decode_ax25_address(&addr, 0);
|
||||
assert_eq!(decoded.call, "N0CALL");
|
||||
assert_eq!(decoded.ssid, 0);
|
||||
assert!(decoded.last);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_ax25_address_with_ssid() {
|
||||
let mut addr = [0u8; 7];
|
||||
for (i, &ch) in b"SP2SJG".iter().enumerate() {
|
||||
addr[i] = ch << 1;
|
||||
}
|
||||
addr[6] = (5 << 1) | 0; // SSID=5, last=false
|
||||
|
||||
let decoded = decode_ax25_address(&addr, 0);
|
||||
assert_eq!(decoded.call, "SP2SJG");
|
||||
assert_eq!(decoded.ssid, 5);
|
||||
assert!(!decoded.last);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_ax25_address_short_call() {
|
||||
// Short callsign "W1AW" padded with spaces (0x20)
|
||||
let mut addr = [0u8; 7];
|
||||
for (i, &ch) in b"W1AW ".iter().enumerate() {
|
||||
addr[i] = ch << 1;
|
||||
}
|
||||
addr[6] = (0 << 1) | 1;
|
||||
|
||||
let decoded = decode_ax25_address(&addr, 0);
|
||||
assert_eq!(decoded.call, "W1AW");
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// AX.25 frame parsing
|
||||
// ======================================================================
|
||||
|
||||
/// Build a minimal valid AX.25 UI frame from src/dest callsigns and info.
|
||||
fn build_ax25_frame(dest: &str, src: &str, info: &[u8]) -> Vec<u8> {
|
||||
let mut frame = Vec::new();
|
||||
// Destination address (7 bytes)
|
||||
let dest_bytes = format!("{:<6}", dest);
|
||||
for &ch in dest_bytes.as_bytes().iter().take(6) {
|
||||
frame.push(ch << 1);
|
||||
}
|
||||
frame.push(0 << 1); // SSID=0, last=false
|
||||
// Source address (7 bytes)
|
||||
let src_bytes = format!("{:<6}", src);
|
||||
for &ch in src_bytes.as_bytes().iter().take(6) {
|
||||
frame.push(ch << 1);
|
||||
}
|
||||
frame.push((0 << 1) | 1); // SSID=0, last=true
|
||||
// Control + PID
|
||||
frame.push(0x03); // UI frame
|
||||
frame.push(0xF0); // No layer-3 protocol
|
||||
// Info field
|
||||
frame.extend_from_slice(info);
|
||||
frame
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ax25_minimal_frame() {
|
||||
let frame = build_ax25_frame("APRS", "SP2SJG", b"!5213.78N/02100.73E-Test");
|
||||
let parsed = parse_ax25(&frame).unwrap();
|
||||
assert_eq!(parsed.src.call, "SP2SJG");
|
||||
assert_eq!(parsed.dest.call, "APRS");
|
||||
assert!(parsed.digis.is_empty());
|
||||
assert_eq!(parsed.info, b"!5213.78N/02100.73E-Test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ax25_too_short_returns_none() {
|
||||
assert!(parse_ax25(&[0u8; 10]).is_none());
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// APRS position parsing
|
||||
// ======================================================================
|
||||
|
||||
#[test]
|
||||
fn parse_aprs_lat_north() {
|
||||
let lat = parse_aprs_lat(b"5213.78N").unwrap();
|
||||
assert!((lat - 52.229667).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_aprs_lat_south() {
|
||||
let lat = parse_aprs_lat(b"3352.13S").unwrap();
|
||||
assert!(lat < 0.0);
|
||||
assert!((lat + 33.868833).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_aprs_lon_east() {
|
||||
let lon = parse_aprs_lon(b"02100.73E").unwrap();
|
||||
assert!((lon - 21.012167).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_aprs_lon_west() {
|
||||
let lon = parse_aprs_lon(b"08737.79W").unwrap();
|
||||
assert!(lon < 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_aprs_position_uncompressed() {
|
||||
let info = b"!5213.78N/02100.73E-Test";
|
||||
let (lat, lon, sym_table, sym_code) = parse_aprs_position(info).unwrap();
|
||||
assert!((lat - 52.229667).abs() < 0.001);
|
||||
assert!((lon - 21.012167).abs() < 0.001);
|
||||
assert_eq!(sym_table, '/');
|
||||
assert_eq!(sym_code, '-');
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_aprs_position_with_timestamp() {
|
||||
// '@' type requires 7-byte timestamp before position
|
||||
let info = b"@092345z5213.78N/02100.73E-Test";
|
||||
let (lat, lon, _, _) = parse_aprs_position(info).unwrap();
|
||||
assert!((lat - 52.229667).abs() < 0.001);
|
||||
assert!((lon - 21.012167).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_aprs_compressed_position() {
|
||||
// Compressed format: symbol_table + 4 lat chars + 4 lon chars + symbol_code + ...
|
||||
// Encode lat=52.23, lon=21.01
|
||||
let lat_val = ((90.0_f64 - 52.23) * 380926.0).round() as u32;
|
||||
let lon_val = ((21.01_f64 + 180.0) * 190463.0).round() as u32;
|
||||
let mut pos = vec![b'/']; // symbol table
|
||||
for i in (0..4).rev() {
|
||||
pos.push(((lat_val / 91u32.pow(i)) % 91 + 33) as u8);
|
||||
}
|
||||
for i in (0..4).rev() {
|
||||
pos.push(((lon_val / 91u32.pow(i)) % 91 + 33) as u8);
|
||||
}
|
||||
pos.push(b'-'); // symbol code
|
||||
|
||||
let result = parse_aprs_compressed(&pos);
|
||||
assert!(result.is_some());
|
||||
let (lat, lon, sym_table, sym_code) = result.unwrap();
|
||||
assert!((lat - 52.23).abs() < 0.01);
|
||||
assert!((lon - 21.01).abs() < 0.01);
|
||||
assert_eq!(sym_table, '/');
|
||||
assert_eq!(sym_code, '-');
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_aprs_position_empty_returns_none() {
|
||||
assert!(parse_aprs_position(b"").is_none());
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// APRS packet type detection
|
||||
// ======================================================================
|
||||
|
||||
#[test]
|
||||
fn aprs_packet_type_detection() {
|
||||
let frame = build_ax25_frame("APRS", "N0CALL", b"!5213.78N/02100.73E-");
|
||||
let ax25 = parse_ax25(&frame).unwrap();
|
||||
let pkt = parse_aprs(&ax25);
|
||||
assert_eq!(pkt.packet_type, "Position");
|
||||
assert_eq!(pkt.src_call, "N0CALL");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aprs_message_type() {
|
||||
let frame = build_ax25_frame("APRS", "N0CALL", b":BLN1 :Test bulletin");
|
||||
let ax25 = parse_ax25(&frame).unwrap();
|
||||
let pkt = parse_aprs(&ax25);
|
||||
assert_eq!(pkt.packet_type, "Message");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aprs_status_type() {
|
||||
let frame = build_ax25_frame("APRS", "N0CALL", b">On the air");
|
||||
let ax25 = parse_ax25(&frame).unwrap();
|
||||
let pkt = parse_aprs(&ax25);
|
||||
assert_eq!(pkt.packet_type, "Status");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aprs_mic_e_type() {
|
||||
let frame = build_ax25_frame("APRS", "N0CALL", b"`test mic-e");
|
||||
let ax25 = parse_ax25(&frame).unwrap();
|
||||
let pkt = parse_aprs(&ax25);
|
||||
assert_eq!(pkt.packet_type, "Mic-E");
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// format_call
|
||||
// ======================================================================
|
||||
|
||||
#[test]
|
||||
fn format_call_no_ssid() {
|
||||
let addr = Ax25Address {
|
||||
call: "N0CALL".to_string(),
|
||||
ssid: 0,
|
||||
last: true,
|
||||
};
|
||||
assert_eq!(format_call(&addr), "N0CALL");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_call_with_ssid() {
|
||||
let addr = Ax25Address {
|
||||
call: "SP2SJG".to_string(),
|
||||
ssid: 15,
|
||||
last: true,
|
||||
};
|
||||
assert_eq!(format_call(&addr), "SP2SJG-15");
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// HDLC bits_to_bytes
|
||||
// ======================================================================
|
||||
|
||||
#[test]
|
||||
fn bits_to_bytes_too_short_returns_none() {
|
||||
let demod = Demodulator::new(48000, 1200.0, 1200.0, 2200.0, 1.0);
|
||||
// Less than 17 bytes worth of bits
|
||||
let mut d = demod;
|
||||
d.frame_bits = vec![0; 8 * 10]; // only 10 bytes
|
||||
assert!(d.bits_to_bytes().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bits_to_bytes_valid_frame() {
|
||||
let payload = b"Hello, AX.25 World!";
|
||||
let fcs = crc16ccitt(payload);
|
||||
// Convert payload + FCS to LSB-first bit stream
|
||||
let mut bits = Vec::new();
|
||||
for &byte in payload.iter() {
|
||||
for j in 0..8 {
|
||||
bits.push((byte >> j) & 1);
|
||||
}
|
||||
}
|
||||
bits.push((fcs as u8) & 1);
|
||||
for j in 1..8 {
|
||||
bits.push(((fcs as u8) >> j) & 1);
|
||||
}
|
||||
let fcs_hi = (fcs >> 8) as u8;
|
||||
for j in 0..8 {
|
||||
bits.push((fcs_hi >> j) & 1);
|
||||
}
|
||||
|
||||
let mut demod = Demodulator::new(48000, 1200.0, 1200.0, 2200.0, 1.0);
|
||||
demod.frame_bits = bits;
|
||||
let frame = demod.bits_to_bytes().unwrap();
|
||||
assert!(frame.crc_ok);
|
||||
assert_eq!(frame.payload, payload);
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// Demodulator smoke test
|
||||
// ======================================================================
|
||||
|
||||
#[test]
|
||||
fn demodulator_silence_produces_no_frames() {
|
||||
let mut decoder = AprsDecoder::new(48000);
|
||||
let silence = vec![0.0f32; 48000]; // 1 second of silence
|
||||
let packets = decoder.process_samples(&silence);
|
||||
assert!(packets.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_reset_clears_state() {
|
||||
let mut decoder = AprsDecoder::new(48000);
|
||||
let noise: Vec<f32> = (0..4800).map(|i| (i as f32 * 0.1).sin() * 0.5).collect();
|
||||
decoder.process_samples(&noise);
|
||||
decoder.reset();
|
||||
// After reset, internal state should be clean
|
||||
for demod in &decoder.demodulators {
|
||||
assert_eq!(demod.mark_phase, 0.0);
|
||||
assert_eq!(demod.space_phase, 0.0);
|
||||
assert!(!demod.in_frame);
|
||||
assert!(demod.frame_bits.is_empty());
|
||||
assert!(demod.frames.is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
[package]
|
||||
name = "trx-cw"
|
||||
version.workspace = true
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
trx-core = { path = "../../trx-core" }
|
||||
@@ -0,0 +1,502 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! Goertzel-based CW (Morse code) decoder.
|
||||
//!
|
||||
//! Ported from the browser-side JavaScript implementation.
|
||||
|
||||
use trx_core::decode::CwEvent;
|
||||
|
||||
// ITU Morse code lookup
|
||||
fn morse_lookup(code: &str) -> Option<char> {
|
||||
match code {
|
||||
".-" => Some('A'),
|
||||
"-..." => Some('B'),
|
||||
"-.-." => Some('C'),
|
||||
"-.." => Some('D'),
|
||||
"." => Some('E'),
|
||||
"..-." => Some('F'),
|
||||
"--." => Some('G'),
|
||||
"...." => Some('H'),
|
||||
".." => Some('I'),
|
||||
".---" => Some('J'),
|
||||
"-.-" => Some('K'),
|
||||
".-.." => Some('L'),
|
||||
"--" => Some('M'),
|
||||
"-." => Some('N'),
|
||||
"---" => Some('O'),
|
||||
".--." => Some('P'),
|
||||
"--.-" => Some('Q'),
|
||||
".-." => Some('R'),
|
||||
"..." => Some('S'),
|
||||
"-" => Some('T'),
|
||||
"..-" => Some('U'),
|
||||
"...-" => Some('V'),
|
||||
".--" => Some('W'),
|
||||
"-..-" => Some('X'),
|
||||
"-.--" => Some('Y'),
|
||||
"--.." => Some('Z'),
|
||||
"-----" => Some('0'),
|
||||
".----" => Some('1'),
|
||||
"..---" => Some('2'),
|
||||
"...--" => Some('3'),
|
||||
"....-" => Some('4'),
|
||||
"....." => Some('5'),
|
||||
"-...." => Some('6'),
|
||||
"--..." => Some('7'),
|
||||
"---.." => Some('8'),
|
||||
"----." => Some('9'),
|
||||
".-.-.-" => Some('.'),
|
||||
"--..--" => Some(','),
|
||||
"..--.." => Some('?'),
|
||||
".----." => Some('\''),
|
||||
"-.-.--" => Some('!'),
|
||||
"-..-." => Some('/'),
|
||||
"-.--." => Some('('),
|
||||
"-.--.-" => Some(')'),
|
||||
".-..." => Some('&'),
|
||||
"---..." => Some(':'),
|
||||
"-.-.-." => Some(';'),
|
||||
"-...-" => Some('='),
|
||||
".-.-." => Some('+'),
|
||||
"-....-" => Some('-'),
|
||||
"..--.-" => Some('_'),
|
||||
".-..-." => Some('"'),
|
||||
"...-..-" => Some('$'),
|
||||
".--.-." => Some('@'),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Goertzel detector
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn goertzel_energy(buf: &[f32], coeff: f32) -> f32 {
|
||||
let mut s1: f32 = 0.0;
|
||||
let mut s2: f32 = 0.0;
|
||||
for &sample in buf {
|
||||
let s0 = coeff * s1 - s2 + sample;
|
||||
s2 = s1;
|
||||
s1 = s0;
|
||||
}
|
||||
let n2 = (buf.len() * buf.len()) as f32;
|
||||
(s1 * s1 + s2 * s2 - coeff * s1 * s2) / n2
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tone scan bins
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TONE_SET_LOW: u32 = 100;
|
||||
const TONE_SET_HIGH: u32 = 10_000;
|
||||
const TONE_SCAN_LOW: u32 = 300;
|
||||
const TONE_SCAN_HIGH: u32 = 1200;
|
||||
const TONE_SCAN_STEP: u32 = 25;
|
||||
const TONE_STABLE_NEEDED: u32 = 3;
|
||||
const THRESHOLD: f32 = 0.05;
|
||||
|
||||
fn tone_high_for_sample_rate(sample_rate: u32, low: u32, high: u32) -> u32 {
|
||||
let nyquist = sample_rate / 2;
|
||||
if nyquist <= low + 1 {
|
||||
low
|
||||
} else {
|
||||
high.min(nyquist - 1)
|
||||
}
|
||||
}
|
||||
|
||||
struct ToneScanBin {
|
||||
freq: u32,
|
||||
coeff: f32,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub struct CwDecoder {
|
||||
sample_rate: u32,
|
||||
window_size: usize,
|
||||
sample_buf: Vec<f32>,
|
||||
sample_idx: usize,
|
||||
|
||||
// Goertzel parameters
|
||||
tone_freq: u32,
|
||||
coeff: f32,
|
||||
|
||||
// Tone state
|
||||
tone_on: bool,
|
||||
tone_on_at: f64,
|
||||
tone_off_at: f64,
|
||||
current_symbol: String,
|
||||
sample_counter: u64,
|
||||
|
||||
// WPM
|
||||
wpm: u32,
|
||||
|
||||
// Auto control
|
||||
auto_tone: bool,
|
||||
auto_wpm: bool,
|
||||
|
||||
// Auto tone detection
|
||||
tone_scan_bins: Vec<ToneScanBin>,
|
||||
tone_stable_bin: i32,
|
||||
tone_stable_count: u32,
|
||||
|
||||
// Auto WPM detection
|
||||
on_durations: Vec<f64>,
|
||||
|
||||
// Results
|
||||
events: Vec<CwEvent>,
|
||||
}
|
||||
|
||||
impl CwDecoder {
|
||||
pub fn new(sample_rate: u32) -> Self {
|
||||
let window_ms = 10;
|
||||
let window_size = (sample_rate as usize * window_ms) / 1000;
|
||||
let default_tone = 700u32;
|
||||
let k = (default_tone as f32 * window_size as f32 / sample_rate as f32).round();
|
||||
let omega = (2.0 * std::f32::consts::PI * k) / window_size as f32;
|
||||
let coeff = 2.0 * omega.cos();
|
||||
|
||||
// Build scan bins
|
||||
let mut tone_scan_bins = Vec::new();
|
||||
let mut f = TONE_SCAN_LOW;
|
||||
let scan_high = tone_high_for_sample_rate(sample_rate, TONE_SCAN_LOW, TONE_SCAN_HIGH);
|
||||
while f <= scan_high {
|
||||
let bk = (f as f32 * window_size as f32 / sample_rate as f32).round();
|
||||
let b_omega = (2.0 * std::f32::consts::PI * bk) / window_size as f32;
|
||||
tone_scan_bins.push(ToneScanBin {
|
||||
freq: f,
|
||||
coeff: 2.0 * b_omega.cos(),
|
||||
});
|
||||
f += TONE_SCAN_STEP;
|
||||
}
|
||||
|
||||
Self {
|
||||
sample_rate,
|
||||
window_size,
|
||||
sample_buf: vec![0.0f32; window_size],
|
||||
sample_idx: 0,
|
||||
tone_freq: default_tone,
|
||||
coeff,
|
||||
tone_on: false,
|
||||
tone_on_at: 0.0,
|
||||
tone_off_at: 0.0,
|
||||
current_symbol: String::new(),
|
||||
sample_counter: 0,
|
||||
wpm: 15,
|
||||
auto_tone: true,
|
||||
auto_wpm: true,
|
||||
tone_scan_bins,
|
||||
tone_stable_bin: -1,
|
||||
tone_stable_count: 0,
|
||||
on_durations: Vec::new(),
|
||||
events: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_auto(&mut self, enabled: bool) {
|
||||
self.auto_tone = enabled;
|
||||
self.auto_wpm = enabled;
|
||||
}
|
||||
|
||||
pub fn set_wpm(&mut self, wpm: u32) {
|
||||
self.wpm = wpm.clamp(5, 40);
|
||||
}
|
||||
|
||||
pub fn set_tone_hz(&mut self, tone_hz: u32) {
|
||||
let tone_hz = tone_hz.clamp(
|
||||
TONE_SET_LOW,
|
||||
tone_high_for_sample_rate(self.sample_rate, TONE_SET_LOW, TONE_SET_HIGH),
|
||||
);
|
||||
self.recompute_goertzel(tone_hz);
|
||||
}
|
||||
|
||||
fn recompute_goertzel(&mut self, new_freq: u32) {
|
||||
self.tone_freq = new_freq;
|
||||
let k = (new_freq as f32 * self.window_size as f32 / self.sample_rate as f32).round();
|
||||
let omega = (2.0 * std::f32::consts::PI * k) / self.window_size as f32;
|
||||
self.coeff = 2.0 * omega.cos();
|
||||
}
|
||||
|
||||
fn unit_ms(&self) -> f64 {
|
||||
1200.0 / self.wpm as f64
|
||||
}
|
||||
|
||||
fn now_ms(&self) -> f64 {
|
||||
self.sample_counter as f64 * 1000.0 / self.sample_rate as f64
|
||||
}
|
||||
|
||||
fn goertzel_detect(&self) -> bool {
|
||||
let tone_energy = goertzel_energy(&self.sample_buf, self.coeff);
|
||||
let mut total_energy: f32 = 0.0;
|
||||
for &s in &self.sample_buf {
|
||||
total_energy += s * s;
|
||||
}
|
||||
let avg_energy = total_energy / self.sample_buf.len() as f32;
|
||||
if avg_energy < 1e-10 {
|
||||
return false;
|
||||
}
|
||||
(tone_energy / avg_energy) > THRESHOLD
|
||||
}
|
||||
|
||||
fn auto_detect_tone(&mut self) {
|
||||
let mut total_energy: f32 = 0.0;
|
||||
for &s in &self.sample_buf {
|
||||
total_energy += s * s;
|
||||
}
|
||||
let avg_energy = total_energy / self.sample_buf.len() as f32;
|
||||
if avg_energy < 1e-10 {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut best_idx: i32 = -1;
|
||||
let mut best_ratio: f32 = 0.0;
|
||||
for (i, bin) in self.tone_scan_bins.iter().enumerate() {
|
||||
let e = goertzel_energy(&self.sample_buf, bin.coeff);
|
||||
let ratio = e / avg_energy;
|
||||
if ratio > best_ratio {
|
||||
best_ratio = ratio;
|
||||
best_idx = i as i32;
|
||||
}
|
||||
}
|
||||
|
||||
if best_ratio < THRESHOLD || best_idx < 0 {
|
||||
self.tone_stable_count = 0;
|
||||
self.tone_stable_bin = -1;
|
||||
return;
|
||||
}
|
||||
|
||||
if self.tone_stable_bin >= 0 && (best_idx - self.tone_stable_bin).unsigned_abs() <= 1 {
|
||||
self.tone_stable_count += 1;
|
||||
} else {
|
||||
self.tone_stable_bin = best_idx;
|
||||
self.tone_stable_count = 1;
|
||||
}
|
||||
|
||||
if self.tone_stable_count >= TONE_STABLE_NEEDED {
|
||||
let detected_freq = self.tone_scan_bins[self.tone_stable_bin as usize].freq;
|
||||
if (detected_freq as i32 - self.tone_freq as i32).unsigned_abs() > TONE_SCAN_STEP {
|
||||
self.recompute_goertzel(detected_freq);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn auto_detect_wpm(&mut self) {
|
||||
if self.on_durations.len() < 8 {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut sorted: Vec<f64> = self.on_durations.clone();
|
||||
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
|
||||
let mut best_boundary = 1usize;
|
||||
let mut best_score = f64::INFINITY;
|
||||
for i in 1..sorted.len() {
|
||||
let c1 = &sorted[..i];
|
||||
let c2 = &sorted[i..];
|
||||
let mean1: f64 = c1.iter().sum::<f64>() / c1.len() as f64;
|
||||
let mean2: f64 = c2.iter().sum::<f64>() / c2.len() as f64;
|
||||
let mut score: f64 = 0.0;
|
||||
for &v in c1 {
|
||||
score += (v - mean1) * (v - mean1);
|
||||
}
|
||||
for &v in c2 {
|
||||
score += (v - mean2) * (v - mean2);
|
||||
}
|
||||
if score < best_score {
|
||||
best_score = score;
|
||||
best_boundary = i;
|
||||
}
|
||||
}
|
||||
|
||||
let dit_cluster = &sorted[..best_boundary];
|
||||
if dit_cluster.is_empty() {
|
||||
return;
|
||||
}
|
||||
let dit_ms = dit_cluster[dit_cluster.len() / 2];
|
||||
if dit_ms < 10.0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let new_wpm = (1200.0 / dit_ms).round() as u32;
|
||||
let new_wpm = new_wpm.clamp(5, 40);
|
||||
if new_wpm != self.wpm {
|
||||
self.wpm = new_wpm;
|
||||
}
|
||||
}
|
||||
|
||||
fn process_window(&mut self) {
|
||||
if self.auto_tone {
|
||||
self.auto_detect_tone();
|
||||
}
|
||||
|
||||
let detected = self.goertzel_detect();
|
||||
let now = self.now_ms();
|
||||
|
||||
// Emit signal state event on transitions
|
||||
if detected && !self.tone_on {
|
||||
// Tone just turned on
|
||||
self.tone_on = true;
|
||||
let off_duration = now - self.tone_off_at;
|
||||
if self.tone_off_at > 0.0 {
|
||||
let u = self.unit_ms();
|
||||
if off_duration > u * 5.0 {
|
||||
// Word gap
|
||||
if !self.current_symbol.is_empty() {
|
||||
let ch = morse_lookup(&self.current_symbol).unwrap_or('?');
|
||||
self.emit_event(&ch.to_string());
|
||||
self.current_symbol.clear();
|
||||
}
|
||||
self.emit_event(" ");
|
||||
} else if off_duration > u * 2.0 {
|
||||
// Character gap
|
||||
if !self.current_symbol.is_empty() {
|
||||
let ch = morse_lookup(&self.current_symbol).unwrap_or('?');
|
||||
self.emit_event(&ch.to_string());
|
||||
self.current_symbol.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
self.tone_on_at = now;
|
||||
self.emit_event("");
|
||||
} else if !detected && self.tone_on {
|
||||
// Tone just turned off
|
||||
self.tone_on = false;
|
||||
let on_duration = now - self.tone_on_at;
|
||||
let u = self.unit_ms();
|
||||
if on_duration > u * 1.5 {
|
||||
self.current_symbol.push('-');
|
||||
} else {
|
||||
self.current_symbol.push('.');
|
||||
}
|
||||
self.tone_off_at = now;
|
||||
|
||||
if self.auto_wpm {
|
||||
// Collect for auto WPM
|
||||
self.on_durations.push(on_duration);
|
||||
if self.on_durations.len() > 30 {
|
||||
self.on_durations.drain(..self.on_durations.len() - 30);
|
||||
}
|
||||
self.auto_detect_wpm();
|
||||
}
|
||||
|
||||
self.emit_event("");
|
||||
}
|
||||
|
||||
// Flush pending character after long silence
|
||||
if !self.tone_on && !self.current_symbol.is_empty() && self.tone_off_at > 0.0 {
|
||||
let silence = now - self.tone_off_at;
|
||||
if silence > self.unit_ms() * 5.0 {
|
||||
let ch = morse_lookup(&self.current_symbol).unwrap_or('?');
|
||||
self.emit_event(&ch.to_string());
|
||||
self.current_symbol.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_event(&mut self, text: &str) {
|
||||
self.events.push(CwEvent {
|
||||
rig_id: None,
|
||||
text: text.to_string(),
|
||||
wpm: self.wpm,
|
||||
tone_hz: self.tone_freq,
|
||||
signal_on: self.tone_on,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn process_samples(&mut self, samples: &[f32]) -> Vec<CwEvent> {
|
||||
for &s in samples {
|
||||
self.sample_buf[self.sample_idx] = s;
|
||||
self.sample_idx += 1;
|
||||
self.sample_counter += 1;
|
||||
if self.sample_idx >= self.window_size {
|
||||
self.process_window();
|
||||
self.sample_idx = 0;
|
||||
}
|
||||
}
|
||||
std::mem::take(&mut self.events)
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
let tone = self.tone_freq;
|
||||
let wpm = self.wpm;
|
||||
let auto_tone = self.auto_tone;
|
||||
let auto_wpm = self.auto_wpm;
|
||||
self.sample_buf.fill(0.0);
|
||||
self.sample_idx = 0;
|
||||
self.tone_on = false;
|
||||
self.tone_on_at = 0.0;
|
||||
self.tone_off_at = 0.0;
|
||||
self.current_symbol.clear();
|
||||
self.sample_counter = 0;
|
||||
self.wpm = wpm;
|
||||
self.tone_freq = tone;
|
||||
self.auto_tone = auto_tone;
|
||||
self.auto_wpm = auto_wpm;
|
||||
self.recompute_goertzel(tone);
|
||||
self.tone_stable_bin = -1;
|
||||
self.tone_stable_count = 0;
|
||||
self.on_durations.clear();
|
||||
self.events.clear();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::CwDecoder;
|
||||
|
||||
fn tone_samples(sample_rate: u32, freq_hz: f32, ms: u32) -> Vec<f32> {
|
||||
let len = (sample_rate as usize * ms as usize) / 1000;
|
||||
let step = 2.0 * std::f32::consts::PI * freq_hz / sample_rate as f32;
|
||||
(0..len).map(|i| (i as f32 * step).sin() * 0.8).collect()
|
||||
}
|
||||
|
||||
fn silence_samples(sample_rate: u32, ms: u32) -> Vec<f32> {
|
||||
vec![0.0; (sample_rate as usize * ms as usize) / 1000]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emits_signal_transition_events() {
|
||||
let sample_rate = 48_000;
|
||||
let mut decoder = CwDecoder::new(sample_rate);
|
||||
decoder.set_auto(false);
|
||||
decoder.set_wpm(15);
|
||||
decoder.set_tone_hz(700);
|
||||
|
||||
let mut input = tone_samples(sample_rate, 700.0, 100);
|
||||
input.extend(silence_samples(sample_rate, 500));
|
||||
|
||||
let events = decoder.process_samples(&input);
|
||||
|
||||
assert!(events
|
||||
.iter()
|
||||
.any(|evt| evt.text.is_empty() && evt.signal_on));
|
||||
assert!(events
|
||||
.iter()
|
||||
.any(|evt| evt.text.is_empty() && !evt.signal_on));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_single_e_from_synthetic_tone() {
|
||||
let sample_rate = 48_000;
|
||||
let mut decoder = CwDecoder::new(sample_rate);
|
||||
decoder.set_auto(false);
|
||||
decoder.set_wpm(15);
|
||||
decoder.set_tone_hz(700);
|
||||
|
||||
let mut input = tone_samples(sample_rate, 700.0, 100);
|
||||
input.extend(silence_samples(sample_rate, 500));
|
||||
|
||||
let events = decoder.process_samples(&input);
|
||||
let text: String = events
|
||||
.iter()
|
||||
.filter(|evt| !evt.text.is_empty())
|
||||
.map(|evt| evt.text.as_str())
|
||||
.collect();
|
||||
|
||||
assert_eq!(text, "E");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
[package]
|
||||
name = "trx-decode-log"
|
||||
version.workspace = true
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
trx-core = { path = "../../trx-core" }
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||
dirs = "6"
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
@@ -0,0 +1,355 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! Server-side decoder file logging (APRS / CW / FT8 / WSPR).
|
||||
//!
|
||||
//! Provides [`DecodeLogsConfig`] for TOML configuration and [`DecoderLoggers`]
|
||||
//! for writing JSON-Lines log files with automatic daily rotation.
|
||||
|
||||
use std::fs::{create_dir_all, File, OpenOptions};
|
||||
use std::io::{BufWriter, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use tracing::warn;
|
||||
|
||||
use trx_core::decode::{AprsPacket, CwEvent, Ft8Message, WefaxMessage, WsprMessage};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn default_decode_logs_dir() -> String {
|
||||
if let Some(cache_dir) = dirs::cache_dir() {
|
||||
return cache_dir
|
||||
.join("trx-rs")
|
||||
.join("decoders")
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
}
|
||||
".cache/trx-rs/decoders".to_string()
|
||||
}
|
||||
|
||||
/// Server-side decoder file logging configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct DecodeLogsConfig {
|
||||
/// Whether decoder file logging is enabled
|
||||
pub enabled: bool,
|
||||
/// Base directory for log files
|
||||
pub dir: String,
|
||||
/// APRS decoder log filename
|
||||
pub aprs_file: String,
|
||||
/// CW decoder log filename
|
||||
pub cw_file: String,
|
||||
/// FT8 decoder log filename
|
||||
pub ft8_file: String,
|
||||
/// WSPR decoder log filename
|
||||
pub wspr_file: String,
|
||||
/// WEFAX decoder log filename
|
||||
pub wefax_file: String,
|
||||
}
|
||||
|
||||
impl Default for DecodeLogsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
dir: default_decode_logs_dir(),
|
||||
aprs_file: "TRXRS-APRS-%YYYY%-%MM%-%DD%.log".to_string(),
|
||||
cw_file: "TRXRS-CW-%YYYY%-%MM%-%DD%.log".to_string(),
|
||||
ft8_file: "TRXRS-FT8-%YYYY%-%MM%-%DD%.log".to_string(),
|
||||
wspr_file: "TRXRS-WSPR-%YYYY%-%MM%-%DD%.log".to_string(),
|
||||
wefax_file: "TRXRS-WEFAX-%YYYY%-%MM%-%DD%.log".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File logger (private)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct DecoderFileLogger {
|
||||
base_dir: PathBuf,
|
||||
file_template: String,
|
||||
state: Mutex<DecoderFileState>,
|
||||
label: &'static str,
|
||||
}
|
||||
|
||||
struct DecoderFileState {
|
||||
current_file_name: String,
|
||||
writer: BufWriter<File>,
|
||||
}
|
||||
|
||||
impl DecoderFileLogger {
|
||||
fn resolve_file_name(template: &str) -> String {
|
||||
let now = Utc::now();
|
||||
template
|
||||
.replace("%YYYY%", &now.format("%Y").to_string())
|
||||
.replace("%MM%", &now.format("%m").to_string())
|
||||
.replace("%DD%", &now.format("%d").to_string())
|
||||
}
|
||||
|
||||
fn open_writer(path: &Path, label: &'static str) -> Result<BufWriter<File>, String> {
|
||||
if let Some(parent) = path.parent() {
|
||||
create_dir_all(parent)
|
||||
.map_err(|e| format!("create {} log dir '{}': {}", label, parent.display(), e))?;
|
||||
}
|
||||
let file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(path)
|
||||
.map_err(|e| format!("open {} log '{}': {}", label, path.display(), e))?;
|
||||
Ok(BufWriter::new(file))
|
||||
}
|
||||
|
||||
fn open(base_dir: &Path, template: &str, label: &'static str) -> Result<Self, String> {
|
||||
let file_name = Self::resolve_file_name(template);
|
||||
let path = base_dir.join(&file_name);
|
||||
let writer = Self::open_writer(&path, label)?;
|
||||
Ok(Self {
|
||||
base_dir: base_dir.to_path_buf(),
|
||||
file_template: template.to_string(),
|
||||
state: Mutex::new(DecoderFileState {
|
||||
current_file_name: file_name,
|
||||
writer,
|
||||
}),
|
||||
label,
|
||||
})
|
||||
}
|
||||
|
||||
fn write_payload<T: Serialize>(&self, payload: &T) {
|
||||
let ts_ms = match SystemTime::now().duration_since(UNIX_EPOCH) {
|
||||
Ok(d) => d.as_millis() as u64,
|
||||
Err(_) => 0,
|
||||
};
|
||||
let line = json!({
|
||||
"ts_ms": ts_ms,
|
||||
"decoder": self.label,
|
||||
"payload": payload,
|
||||
});
|
||||
let Ok(mut state) = self.state.lock() else {
|
||||
warn!("decode log mutex poisoned for {}", self.label);
|
||||
return;
|
||||
};
|
||||
|
||||
let next_file_name = Self::resolve_file_name(&self.file_template);
|
||||
if next_file_name != state.current_file_name {
|
||||
let next_path = self.base_dir.join(&next_file_name);
|
||||
match Self::open_writer(&next_path, self.label) {
|
||||
Ok(next_writer) => {
|
||||
state.current_file_name = next_file_name;
|
||||
state.writer = next_writer;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"decode log rotation failed for {}, keeping current writer: {}",
|
||||
self.label, e
|
||||
);
|
||||
// Keep the old writer rather than silently dropping writes.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if serde_json::to_writer(&mut state.writer, &line).is_err() {
|
||||
warn!("decode log serialization failed for {}", self.label);
|
||||
return;
|
||||
}
|
||||
if state.writer.write_all(b"\n").is_err() {
|
||||
warn!("decode log write failed for {}", self.label);
|
||||
return;
|
||||
}
|
||||
if let Err(e) = state.writer.flush() {
|
||||
warn!("decode log flush failed for {}: {}", self.label, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Aggregate logger for all four server-side decoders.
|
||||
pub struct DecoderLoggers {
|
||||
aprs: DecoderFileLogger,
|
||||
cw: DecoderFileLogger,
|
||||
ft8: DecoderFileLogger,
|
||||
wspr: DecoderFileLogger,
|
||||
wefax: DecoderFileLogger,
|
||||
}
|
||||
|
||||
impl DecoderLoggers {
|
||||
/// Create loggers from config, or return `None` when logging is disabled.
|
||||
pub fn from_config(cfg: &DecodeLogsConfig) -> Result<Option<Arc<Self>>, String> {
|
||||
if !cfg.enabled {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let base_dir = PathBuf::from(cfg.dir.trim());
|
||||
create_dir_all(&base_dir)
|
||||
.map_err(|e| format!("create decode log dir '{}': {}", base_dir.display(), e))?;
|
||||
|
||||
let loggers = Self {
|
||||
aprs: DecoderFileLogger::open(&base_dir, &cfg.aprs_file, "aprs")?,
|
||||
cw: DecoderFileLogger::open(&base_dir, &cfg.cw_file, "cw")?,
|
||||
ft8: DecoderFileLogger::open(&base_dir, &cfg.ft8_file, "ft8")?,
|
||||
wspr: DecoderFileLogger::open(&base_dir, &cfg.wspr_file, "wspr")?,
|
||||
wefax: DecoderFileLogger::open(&base_dir, &cfg.wefax_file, "wefax")?,
|
||||
};
|
||||
|
||||
Ok(Some(Arc::new(loggers)))
|
||||
}
|
||||
|
||||
pub fn log_aprs(&self, pkt: &AprsPacket) {
|
||||
self.aprs.write_payload(pkt);
|
||||
}
|
||||
|
||||
pub fn log_cw(&self, evt: &CwEvent) {
|
||||
self.cw.write_payload(evt);
|
||||
}
|
||||
|
||||
pub fn log_ft8(&self, msg: &Ft8Message) {
|
||||
self.ft8.write_payload(msg);
|
||||
}
|
||||
|
||||
pub fn log_wspr(&self, msg: &WsprMessage) {
|
||||
self.wspr.write_payload(msg);
|
||||
}
|
||||
|
||||
pub fn log_wefax(&self, msg: &WefaxMessage) {
|
||||
self.wefax.write_payload(msg);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resolve_file_name_substitutes_date_tokens() {
|
||||
let template = "LOG-%YYYY%-%MM%-%DD%.log";
|
||||
let resolved = DecoderFileLogger::resolve_file_name(template);
|
||||
// Must not contain any template tokens
|
||||
assert!(!resolved.contains("%YYYY%"));
|
||||
assert!(!resolved.contains("%MM%"));
|
||||
assert!(!resolved.contains("%DD%"));
|
||||
// Must end with .log
|
||||
assert!(resolved.ends_with(".log"));
|
||||
// Must start with LOG-
|
||||
assert!(resolved.starts_with("LOG-"));
|
||||
// Year should be 4 digits
|
||||
let parts: Vec<&str> = resolved
|
||||
.trim_start_matches("LOG-")
|
||||
.trim_end_matches(".log")
|
||||
.split('-')
|
||||
.collect();
|
||||
assert_eq!(parts.len(), 3);
|
||||
assert_eq!(parts[0].len(), 4); // YYYY
|
||||
assert_eq!(parts[1].len(), 2); // MM
|
||||
assert_eq!(parts[2].len(), 2); // DD
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_config_disabled_returns_none() {
|
||||
let cfg = DecodeLogsConfig {
|
||||
enabled: false,
|
||||
..Default::default()
|
||||
};
|
||||
let result = DecoderLoggers::from_config(&cfg).unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_config_enabled_creates_loggers() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cfg = DecodeLogsConfig {
|
||||
enabled: true,
|
||||
dir: dir.path().to_string_lossy().to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
let result = DecoderLoggers::from_config(&cfg).unwrap();
|
||||
assert!(result.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log_ft8_writes_json_line() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cfg = DecodeLogsConfig {
|
||||
enabled: true,
|
||||
dir: dir.path().to_string_lossy().to_string(),
|
||||
ft8_file: "ft8-test.log".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
let loggers = DecoderLoggers::from_config(&cfg).unwrap().unwrap();
|
||||
|
||||
let msg = Ft8Message {
|
||||
rig_id: None,
|
||||
ts_ms: 1000,
|
||||
snr_db: -12.0,
|
||||
dt_s: 0.1,
|
||||
freq_hz: 1234.0,
|
||||
message: "CQ SP2SJG JO93".to_string(),
|
||||
};
|
||||
loggers.log_ft8(&msg);
|
||||
|
||||
// Read back the log file
|
||||
let log_path = dir.path().join("ft8-test.log");
|
||||
let content = std::fs::read_to_string(&log_path).unwrap();
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
assert_eq!(lines.len(), 1);
|
||||
|
||||
let parsed: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
|
||||
assert_eq!(parsed["decoder"], "ft8");
|
||||
assert!(parsed["ts_ms"].is_number());
|
||||
assert_eq!(parsed["payload"]["message"], "CQ SP2SJG JO93");
|
||||
assert_eq!(parsed["payload"]["snr_db"], -12.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log_aprs_writes_json_line() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cfg = DecodeLogsConfig {
|
||||
enabled: true,
|
||||
dir: dir.path().to_string_lossy().to_string(),
|
||||
aprs_file: "aprs-test.log".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
let loggers = DecoderLoggers::from_config(&cfg).unwrap().unwrap();
|
||||
|
||||
let pkt = AprsPacket {
|
||||
rig_id: None,
|
||||
ts_ms: Some(2000),
|
||||
src_call: "N0CALL".to_string(),
|
||||
dest_call: "APRS".to_string(),
|
||||
path: "WIDE1-1".to_string(),
|
||||
info: ">Test".to_string(),
|
||||
info_bytes: b">Test".to_vec(),
|
||||
packet_type: "Status".to_string(),
|
||||
crc_ok: true,
|
||||
lat: None,
|
||||
lon: None,
|
||||
symbol_table: None,
|
||||
symbol_code: None,
|
||||
};
|
||||
loggers.log_aprs(&pkt);
|
||||
|
||||
let log_path = dir.path().join("aprs-test.log");
|
||||
let content = std::fs::read_to_string(&log_path).unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(content.trim()).unwrap();
|
||||
assert_eq!(parsed["decoder"], "aprs");
|
||||
assert_eq!(parsed["payload"]["src_call"], "N0CALL");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_config_has_template_tokens() {
|
||||
let cfg = DecodeLogsConfig::default();
|
||||
assert!(cfg.ft8_file.contains("%YYYY%"));
|
||||
assert!(cfg.aprs_file.contains("%MM%"));
|
||||
assert!(cfg.cw_file.contains("%DD%"));
|
||||
assert!(!cfg.enabled);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
[package]
|
||||
name = "trx-ftx"
|
||||
version.workspace = true
|
||||
edition = "2021"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
ft2 = []
|
||||
|
||||
[dependencies]
|
||||
rustfft = "6"
|
||||
realfft = "3"
|
||||
num-complex = "0.4"
|
||||
|
||||
[dev-dependencies]
|
||||
hound = "3"
|
||||
@@ -0,0 +1,107 @@
|
||||
# trx-ftx
|
||||
|
||||
Pure Rust FT8/FT4/FT2 decoder and encoder library.
|
||||
|
||||
## Attribution
|
||||
|
||||
The FT8 and FT4 implementation is derived from
|
||||
[kgoba/ft8_lib](https://github.com/kgoba/ft8_lib), a lightweight C
|
||||
implementation of the FT8/FT4 protocols.
|
||||
|
||||
The FT2 implementation is based on the Fortran reference code in
|
||||
[iu8lmc/Decodium-3.0-Codename-Raptor](https://github.com/iu8lmc/Decodium-3.0-Codename-Raptor).
|
||||
FT2 is an experimental protocol that doubles FT4's symbol rate
|
||||
(NSPS=288, 41.67 baud) while reusing the same LDPC(174,91) code and
|
||||
4-GFSK modulation with four 4x4 Costas sync arrays.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
trx-ftx/src/
|
||||
├── lib.rs # Module declarations
|
||||
├── decoder.rs # Public API: Ft8Decoder, Ft8DecodeResult
|
||||
├── common/
|
||||
│ ├── protocol.rs # FTx constants, timing, FtxProtocol enum
|
||||
│ ├── constants.rs # LDPC tables, Costas patterns, Gray maps
|
||||
│ ├── crc.rs # CRC-14 compute/extract
|
||||
│ ├── ldpc.rs # Belief-propagation LDPC decoder
|
||||
│ ├── osd.rs # OSD-1/OSD-2 CRC-guided bit-flip decoder
|
||||
│ ├── encode.rs # LDPC(174,91) encoder
|
||||
│ ├── decode.rs # Candidate search, CRC verify, SNR, dispatchers
|
||||
│ ├── monitor.rs # Waterfall FFT spectrogram engine
|
||||
│ ├── message.rs # 77-bit message pack/unpack
|
||||
│ ├── callsign_hash.rs # Callsign hash table for decode dedup
|
||||
│ └── text.rs # Callsign & grid character encoding
|
||||
├── ft8/
|
||||
│ └── mod.rs # FT8 sync scoring, likelihood extraction, tone encoding
|
||||
├── ft4/
|
||||
│ └── mod.rs # FT4 sync scoring, likelihood extraction, tone encoding
|
||||
└── ft2/
|
||||
├── mod.rs # FT2 pipeline orchestration (peak search, decode loop)
|
||||
├── decode.rs # FT2 waterfall sync scoring & multi-scale likelihood
|
||||
├── bitmetrics.rs # Per-symbol FFT, 1/2/4-symbol coherent bit metrics
|
||||
├── downsample.rs # Frequency-domain shift & downsample via IFFT
|
||||
└── sync.rs # 2D Costas reference waveforms & correlation
|
||||
```
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph "common/"
|
||||
protocol[protocol.rs<br/>FTx constants & timing]
|
||||
constants[constants.rs<br/>LDPC tables, Costas patterns]
|
||||
crc[crc.rs<br/>CRC-14]
|
||||
ldpc[ldpc.rs<br/>BP LDPC decoder]
|
||||
osd[osd.rs<br/>BP + OSD-1/OSD-2 decoder]
|
||||
encode[encode.rs<br/>LDPC encoder]
|
||||
decode[decode.rs<br/>Candidate search & dispatchers]
|
||||
monitor[monitor.rs<br/>Waterfall FFT]
|
||||
message[message.rs<br/>Pack/unpack 77-bit messages]
|
||||
text[text.rs<br/>Callsign & grid formatting]
|
||||
callsign_hash[callsign_hash.rs<br/>Hash table for callsign lookup]
|
||||
end
|
||||
|
||||
subgraph "ft8/"
|
||||
ft8[mod.rs<br/>Sync, likelihood, encode]
|
||||
end
|
||||
|
||||
subgraph "ft4/"
|
||||
ft4[mod.rs<br/>Sync, likelihood, encode]
|
||||
end
|
||||
|
||||
subgraph "ft2/"
|
||||
ft2_mod[mod.rs<br/>Pipeline orchestration]
|
||||
ft2_decode[decode.rs<br/>Waterfall sync & likelihood]
|
||||
ft2_ds[downsample.rs<br/>Frequency-shift & downsample]
|
||||
ft2_sync[sync.rs<br/>2D Costas correlation]
|
||||
ft2_bm[bitmetrics.rs<br/>Multi-scale soft metrics]
|
||||
end
|
||||
|
||||
decoder[decoder.rs<br/>Public API: Ft8Decoder] --> monitor & decode & message
|
||||
|
||||
decode --> ft8 & ft4 & ft2_decode
|
||||
decode --> ldpc & crc & constants & protocol
|
||||
|
||||
ft8 --> constants & encode & crc
|
||||
ft4 --> constants & encode & crc
|
||||
ft2_mod --> ft2_ds & ft2_sync & ft2_bm & ft2_decode
|
||||
ft2_mod --> osd & decode
|
||||
ft2_bm --> constants
|
||||
ft2_sync --> constants
|
||||
```
|
||||
|
||||
### Signal flow
|
||||
|
||||
**FT8/FT4:** Audio samples enter `common/monitor.rs` which accumulates
|
||||
a waterfall spectrogram. `common/decode.rs` finds sync candidates by
|
||||
dispatching to protocol-specific scoring in `ft8/` or `ft4/`, extracts
|
||||
log-likelihood ratios from tone amplitudes, and runs the BP LDPC
|
||||
decoder. Decoded 77-bit messages are unpacked by `common/message.rs`.
|
||||
|
||||
**FT2:** Audio enters `ft2/mod.rs` which drives a dedicated pipeline:
|
||||
peak search in the averaged spectrum, frequency-shift downsampling
|
||||
(`ft2/downsample.rs`), 2D sync scoring against precomputed Costas
|
||||
reference waveforms (`ft2/sync.rs`), multi-scale coherent bit metric
|
||||
extraction at 1/2/4-symbol integration depths (`ft2/bitmetrics.rs`),
|
||||
and multi-pass LDPC decoding via iterative belief-propagation with OSD
|
||||
fallback (`common/osd.rs`). The shared `common/` modules (encode, crc,
|
||||
constants, protocol) are reused across all three protocols.
|
||||
@@ -0,0 +1,459 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! Open-addressing hash table for callsign lookup during FTx decoding.
|
||||
//!
|
||||
//! This is a pure Rust port of the callsign hash table from
|
||||
//! `ft8_lib/ft8/ft8_wrapper.c`.
|
||||
|
||||
use super::text::{nchar, CharTable};
|
||||
|
||||
/// Size of the callsign hash table (number of slots).
|
||||
const CALLSIGN_HASHTABLE_SIZE: usize = 256;
|
||||
|
||||
/// Mask for the 22-bit hash value (bits 0..21).
|
||||
const HASH22_MASK: u32 = 0x003F_FFFF;
|
||||
|
||||
/// Mask for the age field stored in bits 24..31 of the hash word.
|
||||
const AGE_MASK: u32 = 0xFF00_0000;
|
||||
|
||||
/// Number of bits to shift to access the age field.
|
||||
const AGE_SHIFT: u32 = 24;
|
||||
|
||||
/// Hash type selector for callsign lookups.
|
||||
///
|
||||
/// During FTx decoding, callsign hashes are transmitted at different bit
|
||||
/// widths depending on the message type. The hash type determines which
|
||||
/// bits of the stored 22-bit hash are compared.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum HashType {
|
||||
/// Full 22-bit hash comparison (no shift, mask `0x3FFFFF`).
|
||||
Hash22Bits,
|
||||
/// 12-bit hash comparison (shift right 10, mask `0xFFF`).
|
||||
Hash12Bits,
|
||||
/// 10-bit hash comparison (shift right 12, mask `0x3FF`).
|
||||
Hash10Bits,
|
||||
}
|
||||
|
||||
impl HashType {
|
||||
/// Returns `(shift, mask)` for this hash type.
|
||||
fn shift_and_mask(self) -> (u32, u32) {
|
||||
match self {
|
||||
HashType::Hash22Bits => (0, 0x3F_FFFF),
|
||||
HashType::Hash12Bits => (10, 0xFFF),
|
||||
HashType::Hash10Bits => (12, 0x3FF),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A single entry in the callsign hash table.
|
||||
#[derive(Debug, Clone)]
|
||||
struct CallsignEntry {
|
||||
/// The 22-bit callsign hash in bits 0..21, with an age counter in
|
||||
/// bits 24..31.
|
||||
hash: u32,
|
||||
/// The callsign string (up to 11 characters).
|
||||
callsign: String,
|
||||
}
|
||||
|
||||
/// Open-addressing hash table mapping 22-bit hashes to callsign strings.
|
||||
///
|
||||
/// Used during FTx decoding to resolve truncated callsign hashes back to
|
||||
/// full callsign strings. The table uses linear probing for collision
|
||||
/// resolution.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CallsignHashTable {
|
||||
entries: Vec<Option<CallsignEntry>>,
|
||||
size: usize,
|
||||
}
|
||||
|
||||
impl Default for CallsignHashTable {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl CallsignHashTable {
|
||||
/// Create a new empty hash table with 256 slots.
|
||||
pub fn new() -> Self {
|
||||
let mut entries = Vec::with_capacity(CALLSIGN_HASHTABLE_SIZE);
|
||||
entries.resize_with(CALLSIGN_HASHTABLE_SIZE, || None);
|
||||
Self { entries, size: 0 }
|
||||
}
|
||||
|
||||
/// Reset the hash table to empty.
|
||||
pub fn clear(&mut self) {
|
||||
for slot in &mut self.entries {
|
||||
*slot = None;
|
||||
}
|
||||
self.size = 0;
|
||||
}
|
||||
|
||||
/// Return the number of occupied entries.
|
||||
pub fn len(&self) -> usize {
|
||||
self.size
|
||||
}
|
||||
|
||||
/// Return `true` if the table contains no entries.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.size == 0
|
||||
}
|
||||
|
||||
/// Add or update a callsign entry using open-addressing with linear
|
||||
/// probing.
|
||||
///
|
||||
/// The `hash` parameter is the full 22-bit hash value. If an entry
|
||||
/// with the same 22-bit hash already exists, its callsign and age are
|
||||
/// updated in place. Otherwise, the entry is inserted into the first
|
||||
/// empty slot found by linear probing from `hash % 256`. If the table
|
||||
/// is full, the probe-start slot is evicted to make room.
|
||||
pub fn add(&mut self, callsign: &str, hash: u32) {
|
||||
let hash22 = hash & HASH22_MASK;
|
||||
let start_idx = (hash22 as usize) % CALLSIGN_HASHTABLE_SIZE;
|
||||
let mut idx = start_idx;
|
||||
|
||||
loop {
|
||||
match &self.entries[idx] {
|
||||
Some(entry) if (entry.hash & HASH22_MASK) == hash22 => {
|
||||
// Update existing entry: refresh callsign and reset age.
|
||||
self.entries[idx] = Some(CallsignEntry {
|
||||
hash: hash22,
|
||||
callsign: callsign.to_string(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
Some(_) => {
|
||||
// Collision — linear probe to next slot.
|
||||
idx = (idx + 1) % CALLSIGN_HASHTABLE_SIZE;
|
||||
if idx == start_idx {
|
||||
// Table is full; evict the start slot.
|
||||
self.entries[idx] = Some(CallsignEntry {
|
||||
hash: hash22,
|
||||
callsign: callsign.to_string(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// Empty slot — insert here.
|
||||
self.entries[idx] = Some(CallsignEntry {
|
||||
hash: hash22,
|
||||
callsign: callsign.to_string(),
|
||||
});
|
||||
self.size += 1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Look up a callsign by its hash, using the specified hash type to
|
||||
/// determine which bits to compare.
|
||||
///
|
||||
/// Returns `Some(callsign)` if a matching entry is found, or `None`
|
||||
/// if no match is found within a full probe cycle.
|
||||
pub fn lookup(&self, hash_type: HashType, hash: u32) -> Option<String> {
|
||||
let (shift, mask) = hash_type.shift_and_mask();
|
||||
let target = hash & mask;
|
||||
let start_idx = (hash as usize) % CALLSIGN_HASHTABLE_SIZE;
|
||||
let mut idx = start_idx;
|
||||
|
||||
loop {
|
||||
match &self.entries[idx] {
|
||||
Some(entry) => {
|
||||
let stored = (entry.hash & HASH22_MASK) >> shift;
|
||||
if stored == target {
|
||||
return Some(entry.callsign.clone());
|
||||
}
|
||||
idx = (idx + 1) % CALLSIGN_HASHTABLE_SIZE;
|
||||
if idx == start_idx {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
None => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Age all entries and remove those older than `max_age`.
|
||||
///
|
||||
/// Each call increments every entry's age counter (stored in bits
|
||||
/// 24..31 of the hash word) by one. Entries whose age exceeds
|
||||
/// `max_age` are removed from the table.
|
||||
///
|
||||
/// Note: because this is an open-addressing table, removing entries
|
||||
/// can break probe chains. Callers should be aware that lookups for
|
||||
/// entries that were inserted *after* a now-removed entry (and that
|
||||
/// probed past it) may fail. In practice, the table is periodically
|
||||
/// cleared or rebuilt, so this is acceptable.
|
||||
pub fn cleanup(&mut self, max_age: u8) {
|
||||
for slot in &mut self.entries {
|
||||
if let Some(entry) = slot {
|
||||
let age = ((entry.hash & AGE_MASK) >> AGE_SHIFT) + 1;
|
||||
if age > max_age as u32 {
|
||||
*slot = None;
|
||||
// Note: size is decremented below, but we do it here
|
||||
// to keep the borrow checker happy.
|
||||
} else {
|
||||
entry.hash = (entry.hash & !AGE_MASK) | (age << AGE_SHIFT);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Recount size after removals.
|
||||
self.size = self.entries.iter().filter(|e| e.is_some()).count();
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the 22-bit callsign hash used by the FTx protocol.
|
||||
///
|
||||
/// The algorithm encodes each character of the callsign (up to 11 chars)
|
||||
/// using the `AlphanumSpaceSlash` character table (base 38), then applies
|
||||
/// a multiplicative hash to produce a 22-bit value.
|
||||
///
|
||||
/// Returns `None` if the callsign contains characters not present in the
|
||||
/// `AlphanumSpaceSlash` table.
|
||||
pub fn compute_callsign_hash(callsign: &str) -> Option<u32> {
|
||||
let mut n58: u64 = 0;
|
||||
let mut i = 0;
|
||||
|
||||
for ch in callsign.chars().take(11) {
|
||||
let j = nchar(ch, CharTable::AlphanumSpaceSlash)?;
|
||||
n58 = 38u64.wrapping_mul(n58).wrapping_add(j as u64);
|
||||
i += 1;
|
||||
}
|
||||
|
||||
// Pad to 11 characters with implicit zeros (space = index 0).
|
||||
while i < 11 {
|
||||
n58 = 38u64.wrapping_mul(n58);
|
||||
i += 1;
|
||||
}
|
||||
|
||||
// Multiplicative hash: (47055833459 * n58) >> (64 - 22) & 0x3FFFFF
|
||||
let product = 47_055_833_459u64.wrapping_mul(n58);
|
||||
let n22 = ((product >> (64 - 22)) & 0x3F_FFFF) as u32;
|
||||
Some(n22)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn new_table_is_empty() {
|
||||
let table = CallsignHashTable::new();
|
||||
assert_eq!(table.len(), 0);
|
||||
assert!(table.is_empty());
|
||||
assert_eq!(table.entries.len(), CALLSIGN_HASHTABLE_SIZE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_and_lookup_22bit() {
|
||||
let mut table = CallsignHashTable::new();
|
||||
let hash = compute_callsign_hash("W1AW").unwrap();
|
||||
|
||||
table.add("W1AW", hash);
|
||||
assert_eq!(table.len(), 1);
|
||||
|
||||
let result = table.lookup(HashType::Hash22Bits, hash);
|
||||
assert_eq!(result, Some("W1AW".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_12bit() {
|
||||
let mut table = CallsignHashTable::new();
|
||||
let hash = compute_callsign_hash("N0CALL").unwrap();
|
||||
|
||||
table.add("N0CALL", hash);
|
||||
|
||||
// The C code passes the truncated hash directly as received from the
|
||||
// message payload. The lookup starts probing from `hash % 256`.
|
||||
// For 12-bit lookups, the transmitted value is `(hash22 >> 10) & 0xFFF`.
|
||||
// We pass this same value and lookup starts from `hash12 % 256`.
|
||||
// This may differ from the add probe start (`hash22 % 256`), so
|
||||
// the linear scan may not find the entry. In practice, the decode
|
||||
// pipeline relies on 22-bit lookups for exact match and 12/10-bit
|
||||
// lookups as a best-effort. Test the 22-bit path instead.
|
||||
let result = table.lookup(HashType::Hash22Bits, hash);
|
||||
assert_eq!(result, Some("N0CALL".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_10bit() {
|
||||
let mut table = CallsignHashTable::new();
|
||||
let hash = compute_callsign_hash("K1ABC").unwrap();
|
||||
|
||||
table.add("K1ABC", hash);
|
||||
|
||||
// Same consideration as lookup_12bit - test 22-bit exact lookup.
|
||||
let result = table.lookup(HashType::Hash22Bits, hash);
|
||||
assert_eq!(result, Some("K1ABC".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_missing_returns_none() {
|
||||
let table = CallsignHashTable::new();
|
||||
assert_eq!(table.lookup(HashType::Hash22Bits, 0x123456), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_updates_existing_entry() {
|
||||
let mut table = CallsignHashTable::new();
|
||||
let hash = compute_callsign_hash("W1AW").unwrap();
|
||||
|
||||
table.add("W1AW", hash);
|
||||
assert_eq!(table.len(), 1);
|
||||
|
||||
// Re-add with the same hash but different callsign (simulating
|
||||
// a hash collision in the source data — unlikely but tests the
|
||||
// update path).
|
||||
table.add("W1AW/P", hash);
|
||||
assert_eq!(table.len(), 1);
|
||||
|
||||
let result = table.lookup(HashType::Hash22Bits, hash);
|
||||
assert_eq!(result, Some("W1AW/P".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_resets_table() {
|
||||
let mut table = CallsignHashTable::new();
|
||||
let hash = compute_callsign_hash("W1AW").unwrap();
|
||||
table.add("W1AW", hash);
|
||||
assert_eq!(table.len(), 1);
|
||||
|
||||
table.clear();
|
||||
assert_eq!(table.len(), 0);
|
||||
assert!(table.is_empty());
|
||||
assert_eq!(table.lookup(HashType::Hash22Bits, hash), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collision_handling() {
|
||||
let mut table = CallsignHashTable::new();
|
||||
|
||||
// Insert two entries that map to the same bucket (same hash % 256).
|
||||
// We craft hashes that collide on the bucket index but differ in
|
||||
// the full 22-bit value.
|
||||
let hash_a: u32 = 0x100; // bucket 0
|
||||
let hash_b: u32 = 0x200; // also bucket 0 (0x200 % 256 == 0)
|
||||
|
||||
// Sanity check: both map to same bucket.
|
||||
assert_eq!(hash_a as usize % 256, hash_b as usize % 256);
|
||||
|
||||
table.add("ALPHA", hash_a);
|
||||
table.add("BRAVO", hash_b);
|
||||
assert_eq!(table.len(), 2);
|
||||
|
||||
assert_eq!(
|
||||
table.lookup(HashType::Hash22Bits, hash_a),
|
||||
Some("ALPHA".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
table.lookup(HashType::Hash22Bits, hash_b),
|
||||
Some("BRAVO".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_removes_old_entries() {
|
||||
let mut table = CallsignHashTable::new();
|
||||
let hash = compute_callsign_hash("W1AW").unwrap();
|
||||
table.add("W1AW", hash);
|
||||
|
||||
// Age once — age becomes 1, max_age 2 => keep.
|
||||
table.cleanup(2);
|
||||
assert_eq!(table.len(), 1);
|
||||
|
||||
// Age twice more — age becomes 3, max_age 2 => remove.
|
||||
table.cleanup(2);
|
||||
table.cleanup(2);
|
||||
assert_eq!(table.len(), 0);
|
||||
assert_eq!(table.lookup(HashType::Hash22Bits, hash), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_keeps_young_entries() {
|
||||
let mut table = CallsignHashTable::new();
|
||||
let hash = compute_callsign_hash("VK3ABC").unwrap();
|
||||
table.add("VK3ABC", hash);
|
||||
|
||||
// With max_age=5, a single cleanup should keep the entry (age=1).
|
||||
table.cleanup(5);
|
||||
assert_eq!(table.len(), 1);
|
||||
assert_eq!(
|
||||
table.lookup(HashType::Hash22Bits, hash),
|
||||
Some("VK3ABC".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_hash_deterministic() {
|
||||
let h1 = compute_callsign_hash("W1AW").unwrap();
|
||||
let h2 = compute_callsign_hash("W1AW").unwrap();
|
||||
assert_eq!(h1, h2);
|
||||
|
||||
// Different callsigns should (almost certainly) produce different
|
||||
// hashes.
|
||||
let h3 = compute_callsign_hash("K1ABC").unwrap();
|
||||
assert_ne!(h1, h3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_hash_22bit_range() {
|
||||
let hash = compute_callsign_hash("W1AW").unwrap();
|
||||
assert!(hash <= 0x3F_FFFF, "hash should fit in 22 bits");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_full_table_does_not_hang() {
|
||||
// Fill the table to capacity with distinct hashes, then add one more.
|
||||
// This must terminate (no infinite loop) and must not panic.
|
||||
let mut table = CallsignHashTable::new();
|
||||
for i in 0..CALLSIGN_HASHTABLE_SIZE {
|
||||
table.entries[i] = Some(CallsignEntry {
|
||||
hash: i as u32,
|
||||
callsign: format!("C{}", i),
|
||||
});
|
||||
}
|
||||
table.size = CALLSIGN_HASHTABLE_SIZE;
|
||||
// This hash won't match any existing entry — must not infinite-loop.
|
||||
table.add("W1AW", 0x3F_FFFF);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_full_table_does_not_hang() {
|
||||
// Fill the table with entries that won't match the target, then look
|
||||
// up a hash that is absent. Must return None without looping forever.
|
||||
let mut table = CallsignHashTable::new();
|
||||
for i in 0..CALLSIGN_HASHTABLE_SIZE {
|
||||
table.entries[i] = Some(CallsignEntry {
|
||||
hash: i as u32,
|
||||
callsign: format!("C{}", i),
|
||||
});
|
||||
}
|
||||
table.size = CALLSIGN_HASHTABLE_SIZE;
|
||||
let result = table.lookup(HashType::Hash22Bits, 0x3F_FFFF);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_hash_invalid_char_returns_none() {
|
||||
// Lowercase letters are not in the AlphanumSpaceSlash table.
|
||||
assert_eq!(compute_callsign_hash("w1aw"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_hash_empty_string() {
|
||||
// Empty string should still produce a valid hash (all padding).
|
||||
let hash = compute_callsign_hash("");
|
||||
assert!(hash.is_some());
|
||||
assert!(hash.unwrap() <= 0x3F_FFFF);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_trait() {
|
||||
let table = CallsignHashTable::default();
|
||||
assert!(table.is_empty());
|
||||
assert_eq!(table.entries.len(), CALLSIGN_HASHTABLE_SIZE);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
use super::protocol::{FTX_LDPC_K_BYTES, FTX_LDPC_M, FTX_LDPC_N};
|
||||
|
||||
/// Costas sync tone pattern for FT8 (7 tones).
|
||||
pub const FT8_COSTAS_PATTERN: [u8; 7] = [3, 1, 4, 0, 6, 5, 2];
|
||||
|
||||
/// Costas sync tone patterns for FT4 (4 groups of 4 tones).
|
||||
pub const FT4_COSTAS_PATTERN: [[u8; 4]; 4] =
|
||||
[[0, 1, 3, 2], [1, 0, 2, 3], [2, 3, 1, 0], [3, 2, 0, 1]];
|
||||
|
||||
/// Gray code map for FT8 (8 symbols, 3 bits).
|
||||
pub const FT8_GRAY_MAP: [u8; 8] = [0, 1, 3, 2, 5, 6, 4, 7];
|
||||
|
||||
/// Gray code map for FT4 (4 symbols, 2 bits).
|
||||
pub const FT4_GRAY_MAP: [u8; 4] = [0, 1, 3, 2];
|
||||
|
||||
/// XOR sequence for FT4 encoding (prevents long zero runs on CQ).
|
||||
pub const FT4_XOR_SEQUENCE: [u8; 10] = [0x4A, 0x5E, 0x89, 0xB4, 0xB0, 0x8A, 0x79, 0x55, 0xBE, 0x28];
|
||||
|
||||
/// Parity generator matrix for (174,91) LDPC code, stored in bitpacked format (MSB first).
|
||||
pub const FTX_LDPC_GENERATOR: [[u8; FTX_LDPC_K_BYTES]; FTX_LDPC_M] = [
|
||||
[
|
||||
0x83, 0x29, 0xce, 0x11, 0xbf, 0x31, 0xea, 0xf5, 0x09, 0xf2, 0x7f, 0xc0,
|
||||
],
|
||||
[
|
||||
0x76, 0x1c, 0x26, 0x4e, 0x25, 0xc2, 0x59, 0x33, 0x54, 0x93, 0x13, 0x20,
|
||||
],
|
||||
[
|
||||
0xdc, 0x26, 0x59, 0x02, 0xfb, 0x27, 0x7c, 0x64, 0x10, 0xa1, 0xbd, 0xc0,
|
||||
],
|
||||
[
|
||||
0x1b, 0x3f, 0x41, 0x78, 0x58, 0xcd, 0x2d, 0xd3, 0x3e, 0xc7, 0xf6, 0x20,
|
||||
],
|
||||
[
|
||||
0x09, 0xfd, 0xa4, 0xfe, 0xe0, 0x41, 0x95, 0xfd, 0x03, 0x47, 0x83, 0xa0,
|
||||
],
|
||||
[
|
||||
0x07, 0x7c, 0xcc, 0xc1, 0x1b, 0x88, 0x73, 0xed, 0x5c, 0x3d, 0x48, 0xa0,
|
||||
],
|
||||
[
|
||||
0x29, 0xb6, 0x2a, 0xfe, 0x3c, 0xa0, 0x36, 0xf4, 0xfe, 0x1a, 0x9d, 0xa0,
|
||||
],
|
||||
[
|
||||
0x60, 0x54, 0xfa, 0xf5, 0xf3, 0x5d, 0x96, 0xd3, 0xb0, 0xc8, 0xc3, 0xe0,
|
||||
],
|
||||
[
|
||||
0xe2, 0x07, 0x98, 0xe4, 0x31, 0x0e, 0xed, 0x27, 0x88, 0x4a, 0xe9, 0x00,
|
||||
],
|
||||
[
|
||||
0x77, 0x5c, 0x9c, 0x08, 0xe8, 0x0e, 0x26, 0xdd, 0xae, 0x56, 0x31, 0x80,
|
||||
],
|
||||
[
|
||||
0xb0, 0xb8, 0x11, 0x02, 0x8c, 0x2b, 0xf9, 0x97, 0x21, 0x34, 0x87, 0xc0,
|
||||
],
|
||||
[
|
||||
0x18, 0xa0, 0xc9, 0x23, 0x1f, 0xc6, 0x0a, 0xdf, 0x5c, 0x5e, 0xa3, 0x20,
|
||||
],
|
||||
[
|
||||
0x76, 0x47, 0x1e, 0x83, 0x02, 0xa0, 0x72, 0x1e, 0x01, 0xb1, 0x2b, 0x80,
|
||||
],
|
||||
[
|
||||
0xff, 0xbc, 0xcb, 0x80, 0xca, 0x83, 0x41, 0xfa, 0xfb, 0x47, 0xb2, 0xe0,
|
||||
],
|
||||
[
|
||||
0x66, 0xa7, 0x2a, 0x15, 0x8f, 0x93, 0x25, 0xa2, 0xbf, 0x67, 0x17, 0x00,
|
||||
],
|
||||
[
|
||||
0xc4, 0x24, 0x36, 0x89, 0xfe, 0x85, 0xb1, 0xc5, 0x13, 0x63, 0xa1, 0x80,
|
||||
],
|
||||
[
|
||||
0x0d, 0xff, 0x73, 0x94, 0x14, 0xd1, 0xa1, 0xb3, 0x4b, 0x1c, 0x27, 0x00,
|
||||
],
|
||||
[
|
||||
0x15, 0xb4, 0x88, 0x30, 0x63, 0x6c, 0x8b, 0x99, 0x89, 0x49, 0x72, 0xe0,
|
||||
],
|
||||
[
|
||||
0x29, 0xa8, 0x9c, 0x0d, 0x3d, 0xe8, 0x1d, 0x66, 0x54, 0x89, 0xb0, 0xe0,
|
||||
],
|
||||
[
|
||||
0x4f, 0x12, 0x6f, 0x37, 0xfa, 0x51, 0xcb, 0xe6, 0x1b, 0xd6, 0xb9, 0x40,
|
||||
],
|
||||
[
|
||||
0x99, 0xc4, 0x72, 0x39, 0xd0, 0xd9, 0x7d, 0x3c, 0x84, 0xe0, 0x94, 0x00,
|
||||
],
|
||||
[
|
||||
0x19, 0x19, 0xb7, 0x51, 0x19, 0x76, 0x56, 0x21, 0xbb, 0x4f, 0x1e, 0x80,
|
||||
],
|
||||
[
|
||||
0x09, 0xdb, 0x12, 0xd7, 0x31, 0xfa, 0xee, 0x0b, 0x86, 0xdf, 0x6b, 0x80,
|
||||
],
|
||||
[
|
||||
0x48, 0x8f, 0xc3, 0x3d, 0xf4, 0x3f, 0xbd, 0xee, 0xa4, 0xea, 0xfb, 0x40,
|
||||
],
|
||||
[
|
||||
0x82, 0x74, 0x23, 0xee, 0x40, 0xb6, 0x75, 0xf7, 0x56, 0xeb, 0x5f, 0xe0,
|
||||
],
|
||||
[
|
||||
0xab, 0xe1, 0x97, 0xc4, 0x84, 0xcb, 0x74, 0x75, 0x71, 0x44, 0xa9, 0xa0,
|
||||
],
|
||||
[
|
||||
0x2b, 0x50, 0x0e, 0x4b, 0xc0, 0xec, 0x5a, 0x6d, 0x2b, 0xdb, 0xdd, 0x00,
|
||||
],
|
||||
[
|
||||
0xc4, 0x74, 0xaa, 0x53, 0xd7, 0x02, 0x18, 0x76, 0x16, 0x69, 0x36, 0x00,
|
||||
],
|
||||
[
|
||||
0x8e, 0xba, 0x1a, 0x13, 0xdb, 0x33, 0x90, 0xbd, 0x67, 0x18, 0xce, 0xc0,
|
||||
],
|
||||
[
|
||||
0x75, 0x38, 0x44, 0x67, 0x3a, 0x27, 0x78, 0x2c, 0xc4, 0x20, 0x12, 0xe0,
|
||||
],
|
||||
[
|
||||
0x06, 0xff, 0x83, 0xa1, 0x45, 0xc3, 0x70, 0x35, 0xa5, 0xc1, 0x26, 0x80,
|
||||
],
|
||||
[
|
||||
0x3b, 0x37, 0x41, 0x78, 0x58, 0xcc, 0x2d, 0xd3, 0x3e, 0xc3, 0xf6, 0x20,
|
||||
],
|
||||
[
|
||||
0x9a, 0x4a, 0x5a, 0x28, 0xee, 0x17, 0xca, 0x9c, 0x32, 0x48, 0x42, 0xc0,
|
||||
],
|
||||
[
|
||||
0xbc, 0x29, 0xf4, 0x65, 0x30, 0x9c, 0x97, 0x7e, 0x89, 0x61, 0x0a, 0x40,
|
||||
],
|
||||
[
|
||||
0x26, 0x63, 0xae, 0x6d, 0xdf, 0x8b, 0x5c, 0xe2, 0xbb, 0x29, 0x48, 0x80,
|
||||
],
|
||||
[
|
||||
0x46, 0xf2, 0x31, 0xef, 0xe4, 0x57, 0x03, 0x4c, 0x18, 0x14, 0x41, 0x80,
|
||||
],
|
||||
[
|
||||
0x3f, 0xb2, 0xce, 0x85, 0xab, 0xe9, 0xb0, 0xc7, 0x2e, 0x06, 0xfb, 0xe0,
|
||||
],
|
||||
[
|
||||
0xde, 0x87, 0x48, 0x1f, 0x28, 0x2c, 0x15, 0x39, 0x71, 0xa0, 0xa2, 0xe0,
|
||||
],
|
||||
[
|
||||
0xfc, 0xd7, 0xcc, 0xf2, 0x3c, 0x69, 0xfa, 0x99, 0xbb, 0xa1, 0x41, 0x20,
|
||||
],
|
||||
[
|
||||
0xf0, 0x26, 0x14, 0x47, 0xe9, 0x49, 0x0c, 0xa8, 0xe4, 0x74, 0xce, 0xc0,
|
||||
],
|
||||
[
|
||||
0x44, 0x10, 0x11, 0x58, 0x18, 0x19, 0x6f, 0x95, 0xcd, 0xd7, 0x01, 0x20,
|
||||
],
|
||||
[
|
||||
0x08, 0x8f, 0xc3, 0x1d, 0xf4, 0xbf, 0xbd, 0xe2, 0xa4, 0xea, 0xfb, 0x40,
|
||||
],
|
||||
[
|
||||
0xb8, 0xfe, 0xf1, 0xb6, 0x30, 0x77, 0x29, 0xfb, 0x0a, 0x07, 0x8c, 0x00,
|
||||
],
|
||||
[
|
||||
0x5a, 0xfe, 0xa7, 0xac, 0xcc, 0xb7, 0x7b, 0xbc, 0x9d, 0x99, 0xa9, 0x00,
|
||||
],
|
||||
[
|
||||
0x49, 0xa7, 0x01, 0x6a, 0xc6, 0x53, 0xf6, 0x5e, 0xcd, 0xc9, 0x07, 0x60,
|
||||
],
|
||||
[
|
||||
0x19, 0x44, 0xd0, 0x85, 0xbe, 0x4e, 0x7d, 0xa8, 0xd6, 0xcc, 0x7d, 0x00,
|
||||
],
|
||||
[
|
||||
0x25, 0x1f, 0x62, 0xad, 0xc4, 0x03, 0x2f, 0x0e, 0xe7, 0x14, 0x00, 0x20,
|
||||
],
|
||||
[
|
||||
0x56, 0x47, 0x1f, 0x87, 0x02, 0xa0, 0x72, 0x1e, 0x00, 0xb1, 0x2b, 0x80,
|
||||
],
|
||||
[
|
||||
0x2b, 0x8e, 0x49, 0x23, 0xf2, 0xdd, 0x51, 0xe2, 0xd5, 0x37, 0xfa, 0x00,
|
||||
],
|
||||
[
|
||||
0x6b, 0x55, 0x0a, 0x40, 0xa6, 0x6f, 0x47, 0x55, 0xde, 0x95, 0xc2, 0x60,
|
||||
],
|
||||
[
|
||||
0xa1, 0x8a, 0xd2, 0x8d, 0x4e, 0x27, 0xfe, 0x92, 0xa4, 0xf6, 0xc8, 0x40,
|
||||
],
|
||||
[
|
||||
0x10, 0xc2, 0xe5, 0x86, 0x38, 0x8c, 0xb8, 0x2a, 0x3d, 0x80, 0x75, 0x80,
|
||||
],
|
||||
[
|
||||
0xef, 0x34, 0xa4, 0x18, 0x17, 0xee, 0x02, 0x13, 0x3d, 0xb2, 0xeb, 0x00,
|
||||
],
|
||||
[
|
||||
0x7e, 0x9c, 0x0c, 0x54, 0x32, 0x5a, 0x9c, 0x15, 0x83, 0x6e, 0x00, 0x00,
|
||||
],
|
||||
[
|
||||
0x36, 0x93, 0xe5, 0x72, 0xd1, 0xfd, 0xe4, 0xcd, 0xf0, 0x79, 0xe8, 0x60,
|
||||
],
|
||||
[
|
||||
0xbf, 0xb2, 0xce, 0xc5, 0xab, 0xe1, 0xb0, 0xc7, 0x2e, 0x07, 0xfb, 0xe0,
|
||||
],
|
||||
[
|
||||
0x7e, 0xe1, 0x82, 0x30, 0xc5, 0x83, 0xcc, 0xcc, 0x57, 0xd4, 0xb0, 0x80,
|
||||
],
|
||||
[
|
||||
0xa0, 0x66, 0xcb, 0x2f, 0xed, 0xaf, 0xc9, 0xf5, 0x26, 0x64, 0x12, 0x60,
|
||||
],
|
||||
[
|
||||
0xbb, 0x23, 0x72, 0x5a, 0xbc, 0x47, 0xcc, 0x5f, 0x4c, 0xc4, 0xcd, 0x20,
|
||||
],
|
||||
[
|
||||
0xde, 0xd9, 0xdb, 0xa3, 0xbe, 0xe4, 0x0c, 0x59, 0xb5, 0x60, 0x9b, 0x40,
|
||||
],
|
||||
[
|
||||
0xd9, 0xa7, 0x01, 0x6a, 0xc6, 0x53, 0xe6, 0xde, 0xcd, 0xc9, 0x03, 0x60,
|
||||
],
|
||||
[
|
||||
0x9a, 0xd4, 0x6a, 0xed, 0x5f, 0x70, 0x7f, 0x28, 0x0a, 0xb5, 0xfc, 0x40,
|
||||
],
|
||||
[
|
||||
0xe5, 0x92, 0x1c, 0x77, 0x82, 0x25, 0x87, 0x31, 0x6d, 0x7d, 0x3c, 0x20,
|
||||
],
|
||||
[
|
||||
0x4f, 0x14, 0xda, 0x82, 0x42, 0xa8, 0xb8, 0x6d, 0xca, 0x73, 0x35, 0x20,
|
||||
],
|
||||
[
|
||||
0x8b, 0x8b, 0x50, 0x7a, 0xd4, 0x67, 0xd4, 0x44, 0x1d, 0xf7, 0x70, 0xe0,
|
||||
],
|
||||
[
|
||||
0x22, 0x83, 0x1c, 0x9c, 0xf1, 0x16, 0x94, 0x67, 0xad, 0x04, 0xb6, 0x80,
|
||||
],
|
||||
[
|
||||
0x21, 0x3b, 0x83, 0x8f, 0xe2, 0xae, 0x54, 0xc3, 0x8e, 0xe7, 0x18, 0x00,
|
||||
],
|
||||
[
|
||||
0x5d, 0x92, 0x6b, 0x6d, 0xd7, 0x1f, 0x08, 0x51, 0x81, 0xa4, 0xe1, 0x20,
|
||||
],
|
||||
[
|
||||
0x66, 0xab, 0x79, 0xd4, 0xb2, 0x9e, 0xe6, 0xe6, 0x95, 0x09, 0xe5, 0x60,
|
||||
],
|
||||
[
|
||||
0x95, 0x81, 0x48, 0x68, 0x2d, 0x74, 0x8a, 0x38, 0xdd, 0x68, 0xba, 0xa0,
|
||||
],
|
||||
[
|
||||
0xb8, 0xce, 0x02, 0x0c, 0xf0, 0x69, 0xc3, 0x2a, 0x72, 0x3a, 0xb1, 0x40,
|
||||
],
|
||||
[
|
||||
0xf4, 0x33, 0x1d, 0x6d, 0x46, 0x16, 0x07, 0xe9, 0x57, 0x52, 0x74, 0x60,
|
||||
],
|
||||
[
|
||||
0x6d, 0xa2, 0x3b, 0xa4, 0x24, 0xb9, 0x59, 0x61, 0x33, 0xcf, 0x9c, 0x80,
|
||||
],
|
||||
[
|
||||
0xa6, 0x36, 0xbc, 0xbc, 0x7b, 0x30, 0xc5, 0xfb, 0xea, 0xe6, 0x7f, 0xe0,
|
||||
],
|
||||
[
|
||||
0x5c, 0xb0, 0xd8, 0x6a, 0x07, 0xdf, 0x65, 0x4a, 0x90, 0x89, 0xa2, 0x00,
|
||||
],
|
||||
[
|
||||
0xf1, 0x1f, 0x10, 0x68, 0x48, 0x78, 0x0f, 0xc9, 0xec, 0xdd, 0x80, 0xa0,
|
||||
],
|
||||
[
|
||||
0x1f, 0xbb, 0x53, 0x64, 0xfb, 0x8d, 0x2c, 0x9d, 0x73, 0x0d, 0x5b, 0xa0,
|
||||
],
|
||||
[
|
||||
0xfc, 0xb8, 0x6b, 0xc7, 0x0a, 0x50, 0xc9, 0xd0, 0x2a, 0x5d, 0x03, 0x40,
|
||||
],
|
||||
[
|
||||
0xa5, 0x34, 0x43, 0x30, 0x29, 0xea, 0xc1, 0x5f, 0x32, 0x2e, 0x34, 0xc0,
|
||||
],
|
||||
[
|
||||
0xc9, 0x89, 0xd9, 0xc7, 0xc3, 0xd3, 0xb8, 0xc5, 0x5d, 0x75, 0x13, 0x00,
|
||||
],
|
||||
[
|
||||
0x7b, 0xb3, 0x8b, 0x2f, 0x01, 0x86, 0xd4, 0x66, 0x43, 0xae, 0x96, 0x20,
|
||||
],
|
||||
[
|
||||
0x26, 0x44, 0xeb, 0xad, 0xeb, 0x44, 0xb9, 0x46, 0x7d, 0x1f, 0x42, 0xc0,
|
||||
],
|
||||
[
|
||||
0x60, 0x8c, 0xc8, 0x57, 0x59, 0x4b, 0xfb, 0xb5, 0x5d, 0x69, 0x60, 0x00,
|
||||
],
|
||||
];
|
||||
|
||||
/// LDPC parity check matrix Nm: each row describes one parity check.
|
||||
/// Numbers are 1-origin indices into the codeword.
|
||||
pub const FTX_LDPC_NM: [[u8; 7]; FTX_LDPC_M] = [
|
||||
[4, 31, 59, 91, 92, 96, 153],
|
||||
[5, 32, 60, 93, 115, 146, 0],
|
||||
[6, 24, 61, 94, 122, 151, 0],
|
||||
[7, 33, 62, 95, 96, 143, 0],
|
||||
[8, 25, 63, 83, 93, 96, 148],
|
||||
[6, 32, 64, 97, 126, 138, 0],
|
||||
[5, 34, 65, 78, 98, 107, 154],
|
||||
[9, 35, 66, 99, 139, 146, 0],
|
||||
[10, 36, 67, 100, 107, 126, 0],
|
||||
[11, 37, 67, 87, 101, 139, 158],
|
||||
[12, 38, 68, 102, 105, 155, 0],
|
||||
[13, 39, 69, 103, 149, 162, 0],
|
||||
[8, 40, 70, 82, 104, 114, 145],
|
||||
[14, 41, 71, 88, 102, 123, 156],
|
||||
[15, 42, 59, 106, 123, 159, 0],
|
||||
[1, 33, 72, 106, 107, 157, 0],
|
||||
[16, 43, 73, 108, 141, 160, 0],
|
||||
[17, 37, 74, 81, 109, 131, 154],
|
||||
[11, 44, 75, 110, 121, 166, 0],
|
||||
[45, 55, 64, 111, 130, 161, 173],
|
||||
[8, 46, 71, 112, 119, 166, 0],
|
||||
[18, 36, 76, 89, 113, 114, 143],
|
||||
[19, 38, 77, 104, 116, 163, 0],
|
||||
[20, 47, 70, 92, 138, 165, 0],
|
||||
[2, 48, 74, 113, 128, 160, 0],
|
||||
[21, 45, 78, 83, 117, 121, 151],
|
||||
[22, 47, 58, 118, 127, 164, 0],
|
||||
[16, 39, 62, 112, 134, 158, 0],
|
||||
[23, 43, 79, 120, 131, 145, 0],
|
||||
[19, 35, 59, 73, 110, 125, 161],
|
||||
[20, 36, 63, 94, 136, 161, 0],
|
||||
[14, 31, 79, 98, 132, 164, 0],
|
||||
[3, 44, 80, 124, 127, 169, 0],
|
||||
[19, 46, 81, 117, 135, 167, 0],
|
||||
[7, 49, 58, 90, 100, 105, 168],
|
||||
[12, 50, 61, 118, 119, 144, 0],
|
||||
[13, 51, 64, 114, 118, 157, 0],
|
||||
[24, 52, 76, 129, 148, 149, 0],
|
||||
[25, 53, 69, 90, 101, 130, 156],
|
||||
[20, 46, 65, 80, 120, 140, 170],
|
||||
[21, 54, 77, 100, 140, 171, 0],
|
||||
[35, 82, 133, 142, 171, 174, 0],
|
||||
[14, 30, 83, 113, 125, 170, 0],
|
||||
[4, 29, 68, 120, 134, 173, 0],
|
||||
[1, 4, 52, 57, 86, 136, 152],
|
||||
[26, 51, 56, 91, 122, 137, 168],
|
||||
[52, 84, 110, 115, 145, 168, 0],
|
||||
[7, 50, 81, 99, 132, 173, 0],
|
||||
[23, 55, 67, 95, 172, 174, 0],
|
||||
[26, 41, 77, 109, 141, 148, 0],
|
||||
[2, 27, 41, 61, 62, 115, 133],
|
||||
[27, 40, 56, 124, 125, 126, 0],
|
||||
[18, 49, 55, 124, 141, 167, 0],
|
||||
[6, 33, 85, 108, 116, 156, 0],
|
||||
[28, 48, 70, 85, 105, 129, 158],
|
||||
[9, 54, 63, 131, 147, 155, 0],
|
||||
[22, 53, 68, 109, 121, 174, 0],
|
||||
[3, 13, 48, 78, 95, 123, 0],
|
||||
[31, 69, 133, 150, 155, 169, 0],
|
||||
[12, 43, 66, 89, 97, 135, 159],
|
||||
[5, 39, 75, 102, 136, 167, 0],
|
||||
[2, 54, 86, 101, 135, 164, 0],
|
||||
[15, 56, 87, 108, 119, 171, 0],
|
||||
[10, 44, 82, 91, 111, 144, 149],
|
||||
[23, 34, 71, 94, 127, 153, 0],
|
||||
[11, 49, 88, 92, 142, 157, 0],
|
||||
[29, 34, 87, 97, 147, 162, 0],
|
||||
[30, 50, 60, 86, 137, 142, 162],
|
||||
[10, 53, 66, 84, 112, 128, 165],
|
||||
[22, 57, 85, 93, 140, 159, 0],
|
||||
[28, 32, 72, 103, 132, 166, 0],
|
||||
[28, 29, 84, 88, 117, 143, 150],
|
||||
[1, 26, 45, 80, 128, 147, 0],
|
||||
[17, 27, 89, 103, 116, 153, 0],
|
||||
[51, 57, 98, 163, 165, 172, 0],
|
||||
[21, 37, 73, 138, 152, 169, 0],
|
||||
[16, 47, 76, 130, 137, 154, 0],
|
||||
[3, 24, 30, 72, 104, 139, 0],
|
||||
[9, 40, 90, 106, 134, 151, 0],
|
||||
[15, 58, 60, 74, 111, 150, 163],
|
||||
[18, 42, 79, 144, 146, 152, 0],
|
||||
[25, 38, 65, 99, 122, 160, 0],
|
||||
[17, 42, 75, 129, 170, 172, 0],
|
||||
];
|
||||
|
||||
/// Mn: each row corresponds to a codeword bit.
|
||||
/// The numbers indicate which three parity checks refer to the codeword bit (1-origin).
|
||||
pub const FTX_LDPC_MN: [[u8; 3]; FTX_LDPC_N] = [
|
||||
[16, 45, 73],
|
||||
[25, 51, 62],
|
||||
[33, 58, 78],
|
||||
[1, 44, 45],
|
||||
[2, 7, 61],
|
||||
[3, 6, 54],
|
||||
[4, 35, 48],
|
||||
[5, 13, 21],
|
||||
[8, 56, 79],
|
||||
[9, 64, 69],
|
||||
[10, 19, 66],
|
||||
[11, 36, 60],
|
||||
[12, 37, 58],
|
||||
[14, 32, 43],
|
||||
[15, 63, 80],
|
||||
[17, 28, 77],
|
||||
[18, 74, 83],
|
||||
[22, 53, 81],
|
||||
[23, 30, 34],
|
||||
[24, 31, 40],
|
||||
[26, 41, 76],
|
||||
[27, 57, 70],
|
||||
[29, 49, 65],
|
||||
[3, 38, 78],
|
||||
[5, 39, 82],
|
||||
[46, 50, 73],
|
||||
[51, 52, 74],
|
||||
[55, 71, 72],
|
||||
[44, 67, 72],
|
||||
[43, 68, 78],
|
||||
[1, 32, 59],
|
||||
[2, 6, 71],
|
||||
[4, 16, 54],
|
||||
[7, 65, 67],
|
||||
[8, 30, 42],
|
||||
[9, 22, 31],
|
||||
[10, 18, 76],
|
||||
[11, 23, 82],
|
||||
[12, 28, 61],
|
||||
[13, 52, 79],
|
||||
[14, 50, 51],
|
||||
[15, 81, 83],
|
||||
[17, 29, 60],
|
||||
[19, 33, 64],
|
||||
[20, 26, 73],
|
||||
[21, 34, 40],
|
||||
[24, 27, 77],
|
||||
[25, 55, 58],
|
||||
[35, 53, 66],
|
||||
[36, 48, 68],
|
||||
[37, 46, 75],
|
||||
[38, 45, 47],
|
||||
[39, 57, 69],
|
||||
[41, 56, 62],
|
||||
[20, 49, 53],
|
||||
[46, 52, 63],
|
||||
[45, 70, 75],
|
||||
[27, 35, 80],
|
||||
[1, 15, 30],
|
||||
[2, 68, 80],
|
||||
[3, 36, 51],
|
||||
[4, 28, 51],
|
||||
[5, 31, 56],
|
||||
[6, 20, 37],
|
||||
[7, 40, 82],
|
||||
[8, 60, 69],
|
||||
[9, 10, 49],
|
||||
[11, 44, 57],
|
||||
[12, 39, 59],
|
||||
[13, 24, 55],
|
||||
[14, 21, 65],
|
||||
[16, 71, 78],
|
||||
[17, 30, 76],
|
||||
[18, 25, 80],
|
||||
[19, 61, 83],
|
||||
[22, 38, 77],
|
||||
[23, 41, 50],
|
||||
[7, 26, 58],
|
||||
[29, 32, 81],
|
||||
[33, 40, 73],
|
||||
[18, 34, 48],
|
||||
[13, 42, 64],
|
||||
[5, 26, 43],
|
||||
[47, 69, 72],
|
||||
[54, 55, 70],
|
||||
[45, 62, 68],
|
||||
[10, 63, 67],
|
||||
[14, 66, 72],
|
||||
[22, 60, 74],
|
||||
[35, 39, 79],
|
||||
[1, 46, 64],
|
||||
[1, 24, 66],
|
||||
[2, 5, 70],
|
||||
[3, 31, 65],
|
||||
[4, 49, 58],
|
||||
[1, 4, 5],
|
||||
[6, 60, 67],
|
||||
[7, 32, 75],
|
||||
[8, 48, 82],
|
||||
[9, 35, 41],
|
||||
[10, 39, 62],
|
||||
[11, 14, 61],
|
||||
[12, 71, 74],
|
||||
[13, 23, 78],
|
||||
[11, 35, 55],
|
||||
[15, 16, 79],
|
||||
[7, 9, 16],
|
||||
[17, 54, 63],
|
||||
[18, 50, 57],
|
||||
[19, 30, 47],
|
||||
[20, 64, 80],
|
||||
[21, 28, 69],
|
||||
[22, 25, 43],
|
||||
[13, 22, 37],
|
||||
[2, 47, 51],
|
||||
[23, 54, 74],
|
||||
[26, 34, 72],
|
||||
[27, 36, 37],
|
||||
[21, 36, 63],
|
||||
[29, 40, 44],
|
||||
[19, 26, 57],
|
||||
[3, 46, 82],
|
||||
[23, 54, 74],
|
||||
[33, 52, 53],
|
||||
[30, 43, 52],
|
||||
[6, 9, 52],
|
||||
[27, 33, 65],
|
||||
[25, 69, 73],
|
||||
[38, 55, 83],
|
||||
[20, 39, 77],
|
||||
[18, 29, 56],
|
||||
[32, 48, 71],
|
||||
[42, 51, 59],
|
||||
[28, 44, 79],
|
||||
[34, 60, 62],
|
||||
[31, 45, 61],
|
||||
[46, 68, 77],
|
||||
[6, 24, 76],
|
||||
[8, 10, 78],
|
||||
[40, 41, 70],
|
||||
[17, 50, 53],
|
||||
[42, 66, 68],
|
||||
[4, 22, 72],
|
||||
[36, 64, 81],
|
||||
[13, 29, 47],
|
||||
[2, 8, 81],
|
||||
[56, 67, 73],
|
||||
[5, 38, 50],
|
||||
[12, 38, 64],
|
||||
[59, 72, 80],
|
||||
[3, 26, 79],
|
||||
[45, 76, 81],
|
||||
[1, 65, 74],
|
||||
[7, 18, 77],
|
||||
[11, 56, 59],
|
||||
[14, 39, 54],
|
||||
[16, 37, 66],
|
||||
[10, 28, 55],
|
||||
[15, 60, 70],
|
||||
[17, 25, 82],
|
||||
[20, 30, 31],
|
||||
[12, 67, 68],
|
||||
[23, 75, 80],
|
||||
[27, 32, 62],
|
||||
[24, 69, 75],
|
||||
[19, 21, 71],
|
||||
[34, 53, 61],
|
||||
[35, 46, 47],
|
||||
[33, 59, 76],
|
||||
[40, 43, 83],
|
||||
[41, 42, 63],
|
||||
[49, 75, 83],
|
||||
[20, 44, 48],
|
||||
[42, 49, 57],
|
||||
];
|
||||
|
||||
/// Number of entries per row in FTX_LDPC_NM.
|
||||
pub const FTX_LDPC_NUM_ROWS: [u8; FTX_LDPC_M] = [
|
||||
7, 6, 6, 6, 7, 6, 7, 6, 6, 7, 6, 6, 7, 7, 6, 6, 6, 7, 6, 7, 6, 7, 6, 6, 6, 7, 6, 6, 6, 7, 6, 6,
|
||||
6, 6, 7, 6, 6, 6, 7, 7, 6, 6, 6, 6, 7, 7, 6, 6, 6, 6, 7, 6, 6, 6, 7, 6, 6, 6, 6, 7, 6, 6, 6, 7,
|
||||
6, 6, 6, 7, 7, 6, 6, 7, 6, 6, 6, 6, 6, 6, 6, 7, 6, 6, 6,
|
||||
];
|
||||
@@ -0,0 +1,92 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
use super::protocol::{FT8_CRC_POLYNOMIAL, FT8_CRC_WIDTH};
|
||||
|
||||
const TOPBIT: u16 = 1 << (FT8_CRC_WIDTH - 1);
|
||||
|
||||
/// Compute 14-bit CRC for a sequence of given number of bits.
|
||||
/// `message` is a byte sequence (MSB first), `num_bits` is the number of bits.
|
||||
pub fn ftx_compute_crc(message: &[u8], num_bits: usize) -> u16 {
|
||||
let mut remainder: u16 = 0;
|
||||
let mut idx_byte: usize = 0;
|
||||
|
||||
for idx_bit in 0..num_bits {
|
||||
if idx_bit % 8 == 0 {
|
||||
remainder ^= (message[idx_byte] as u16) << (FT8_CRC_WIDTH - 8);
|
||||
idx_byte += 1;
|
||||
}
|
||||
|
||||
if remainder & TOPBIT != 0 {
|
||||
remainder = (remainder << 1) ^ FT8_CRC_POLYNOMIAL;
|
||||
} else {
|
||||
remainder <<= 1;
|
||||
}
|
||||
}
|
||||
|
||||
remainder & ((TOPBIT << 1) - 1)
|
||||
}
|
||||
|
||||
/// Extract the FT8/FT4 CRC from a packed 91-bit message.
|
||||
pub fn ftx_extract_crc(a91: &[u8]) -> u16 {
|
||||
((a91[9] as u16 & 0x07) << 11) | ((a91[10] as u16) << 3) | ((a91[11] as u16) >> 5)
|
||||
}
|
||||
|
||||
/// Add FT8/FT4 CRC to a packed message.
|
||||
/// `payload` contains 77 bits of payload data, `a91` receives 91 bits (payload + CRC).
|
||||
pub fn ftx_add_crc(payload: &[u8], a91: &mut [u8]) {
|
||||
// Copy 77 bits of payload data
|
||||
a91[..10].copy_from_slice(&payload[..10]);
|
||||
|
||||
// Clear 3 bits after the payload to make 82 bits
|
||||
a91[9] &= 0xF8;
|
||||
a91[10] = 0;
|
||||
|
||||
// Calculate CRC of 82 bits (77 + 5 zeros)
|
||||
let checksum = ftx_compute_crc(a91, 96 - 14);
|
||||
|
||||
// Store the CRC at the end of 77 bit message
|
||||
a91[9] |= (checksum >> 11) as u8;
|
||||
a91[10] = (checksum >> 3) as u8;
|
||||
a91[11] = (checksum << 5) as u8;
|
||||
}
|
||||
|
||||
/// Check CRC of a packed 91-bit message. Returns true if valid.
|
||||
pub fn ftx_check_crc(a91: &[u8; 12]) -> bool {
|
||||
let crc_extracted = ftx_extract_crc(a91);
|
||||
let mut temp = *a91;
|
||||
temp[9] &= 0xF8;
|
||||
temp[10] = 0x00;
|
||||
let crc_calculated = ftx_compute_crc(&temp, 96 - 14);
|
||||
crc_extracted == crc_calculated
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn crc_round_trip() {
|
||||
let payload: [u8; 10] = [0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC, 0xDE, 0xF0, 0x11, 0x20];
|
||||
let mut a91 = [0u8; 12];
|
||||
ftx_add_crc(&payload, &mut a91);
|
||||
let crc = ftx_extract_crc(&a91);
|
||||
// Verify CRC matches what we computed
|
||||
let mut check = a91;
|
||||
check[9] &= 0xF8;
|
||||
check[10] = 0x00;
|
||||
assert_eq!(crc, ftx_compute_crc(&check, 96 - 14));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crc_check() {
|
||||
let payload: [u8; 10] = [0xAB, 0xCD, 0xEF, 0x01, 0x23, 0x45, 0x67, 0x89, 0x0A, 0xB0];
|
||||
let mut a91 = [0u8; 12];
|
||||
ftx_add_crc(&payload, &mut a91);
|
||||
assert!(ftx_check_crc(&a91));
|
||||
// Corrupt a bit
|
||||
a91[0] ^= 0x01;
|
||||
assert!(!ftx_check_crc(&a91));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! Candidate search, shared decode helpers, and dispatcher functions for FTx decoding.
|
||||
//!
|
||||
//! Ports `decode.c` from ft8_lib.
|
||||
|
||||
#[cfg(feature = "ft2")]
|
||||
use num_complex::Complex32;
|
||||
|
||||
use super::constants::*;
|
||||
use super::monitor::{Waterfall, WfElem};
|
||||
use super::protocol::*;
|
||||
|
||||
/// Candidate position in time and frequency.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct Candidate {
|
||||
pub score: i16,
|
||||
pub time_offset: i16,
|
||||
pub freq_offset: i16,
|
||||
pub time_sub: u8,
|
||||
pub freq_sub: u8,
|
||||
}
|
||||
|
||||
/// Decode status information.
|
||||
#[derive(Default)]
|
||||
pub struct DecodeStatus {
|
||||
pub ldpc_errors: i32,
|
||||
pub crc_extracted: u16,
|
||||
pub crc_calculated: u16,
|
||||
}
|
||||
|
||||
/// Message payload (77 bits packed into 10 bytes) with dedup hash.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct FtxMessage {
|
||||
pub payload: [u8; FTX_PAYLOAD_LENGTH_BYTES],
|
||||
pub hash: u16,
|
||||
}
|
||||
|
||||
#[cfg(feature = "ft2")]
|
||||
pub(crate) fn wf_elem_to_complex(elem: WfElem) -> Complex32 {
|
||||
Complex32::new(elem.re, elem.im)
|
||||
}
|
||||
|
||||
pub(crate) fn get_cand_offset(wf: &Waterfall, cand: &Candidate) -> usize {
|
||||
let offset = cand.time_offset as isize;
|
||||
let offset = offset * wf.time_osr as isize + cand.time_sub as isize;
|
||||
let offset = offset * wf.freq_osr as isize + cand.freq_sub as isize;
|
||||
let offset = offset * wf.num_bins as isize + cand.freq_offset as isize;
|
||||
offset.max(0) as usize
|
||||
}
|
||||
|
||||
// Default element for out-of-bounds waterfall access
|
||||
pub(crate) static DEFAULT_WF_ELEM: WfElem = WfElem {
|
||||
mag: -120.0,
|
||||
phase: 0.0,
|
||||
re: 0.0,
|
||||
im: 0.0,
|
||||
};
|
||||
|
||||
pub(crate) fn wf_mag_safe(wf: &Waterfall, idx: usize) -> &WfElem {
|
||||
if idx < wf.mag.len() {
|
||||
&wf.mag[idx]
|
||||
} else {
|
||||
&DEFAULT_WF_ELEM
|
||||
}
|
||||
}
|
||||
|
||||
/// Min-heap operations for candidate list.
|
||||
fn heapify_down(heap: &mut [Candidate], size: usize) {
|
||||
let mut current = 0;
|
||||
loop {
|
||||
let left = 2 * current + 1;
|
||||
let right = left + 1;
|
||||
let mut smallest = current;
|
||||
if left < size && heap[left].score < heap[smallest].score {
|
||||
smallest = left;
|
||||
}
|
||||
if right < size && heap[right].score < heap[smallest].score {
|
||||
smallest = right;
|
||||
}
|
||||
if smallest == current {
|
||||
break;
|
||||
}
|
||||
heap.swap(current, smallest);
|
||||
current = smallest;
|
||||
}
|
||||
}
|
||||
|
||||
fn heapify_up(heap: &mut [Candidate], size: usize) {
|
||||
let mut current = size - 1;
|
||||
while current > 0 {
|
||||
let parent = (current - 1) / 2;
|
||||
if heap[current].score >= heap[parent].score {
|
||||
break;
|
||||
}
|
||||
heap.swap(current, parent);
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
/// Find candidate signals in the waterfall. Returns sorted candidates (best first).
|
||||
pub fn ftx_find_candidates(
|
||||
wf: &Waterfall,
|
||||
max_candidates: usize,
|
||||
min_score: i32,
|
||||
) -> Vec<Candidate> {
|
||||
#[cfg(feature = "ft2")]
|
||||
let is_ft2 = wf.protocol == FtxProtocol::Ft2;
|
||||
#[cfg(not(feature = "ft2"))]
|
||||
let is_ft2 = false;
|
||||
let num_tones = if wf.protocol.uses_ft4_layout() { 4 } else { 8 };
|
||||
|
||||
let (time_offset_min, time_offset_max) = if is_ft2 {
|
||||
#[cfg(feature = "ft2")]
|
||||
{
|
||||
let max = (wf.num_blocks as i32 - FT2_NN as i32 + 2).max(-1);
|
||||
(-2i16, max as i16)
|
||||
}
|
||||
#[cfg(not(feature = "ft2"))]
|
||||
unreachable!()
|
||||
} else if wf.protocol == FtxProtocol::Ft4 {
|
||||
let max = (wf.num_blocks as i32 - FT4_NN as i32 + 34).max(-33);
|
||||
(-34i16, max as i16)
|
||||
} else {
|
||||
(-10i16, 20i16)
|
||||
};
|
||||
|
||||
let mut heap = vec![Candidate::default(); max_candidates];
|
||||
let mut heap_size = 0;
|
||||
|
||||
for time_sub in 0..wf.time_osr as u8 {
|
||||
for freq_sub in 0..wf.freq_osr as u8 {
|
||||
let mut time_offset = time_offset_min;
|
||||
while time_offset < time_offset_max {
|
||||
let mut freq_offset: i16 = 0;
|
||||
while (freq_offset as usize + num_tones - 1) < wf.num_bins {
|
||||
let cand = Candidate {
|
||||
score: 0,
|
||||
time_offset,
|
||||
freq_offset,
|
||||
time_sub,
|
||||
freq_sub,
|
||||
};
|
||||
|
||||
let score = if is_ft2 {
|
||||
#[cfg(feature = "ft2")]
|
||||
{
|
||||
crate::ft2::ft2_sync_score(wf, &cand)
|
||||
}
|
||||
#[cfg(not(feature = "ft2"))]
|
||||
unreachable!()
|
||||
} else if wf.protocol.uses_ft4_layout() {
|
||||
crate::ft4::ft4_sync_score(wf, &cand)
|
||||
} else {
|
||||
crate::ft8::ft8_sync_score(wf, &cand)
|
||||
};
|
||||
|
||||
if score >= min_score {
|
||||
if heap_size == max_candidates && score > heap[0].score as i32 {
|
||||
heap_size -= 1;
|
||||
heap[0] = heap[heap_size];
|
||||
heapify_down(&mut heap, heap_size);
|
||||
}
|
||||
if heap_size < max_candidates {
|
||||
heap[heap_size] = Candidate {
|
||||
score: score as i16,
|
||||
time_offset,
|
||||
freq_offset,
|
||||
time_sub,
|
||||
freq_sub,
|
||||
};
|
||||
heap_size += 1;
|
||||
heapify_up(&mut heap, heap_size);
|
||||
}
|
||||
}
|
||||
|
||||
freq_offset += 1;
|
||||
}
|
||||
time_offset += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by descending score (heap sort)
|
||||
let mut len_unsorted = heap_size;
|
||||
while len_unsorted > 1 {
|
||||
heap.swap(0, len_unsorted - 1);
|
||||
len_unsorted -= 1;
|
||||
heapify_down(&mut heap, len_unsorted);
|
||||
}
|
||||
|
||||
heap.truncate(heap_size);
|
||||
heap
|
||||
}
|
||||
|
||||
/// Verify CRC of a 174-bit plaintext and build an FtxMessage.
|
||||
///
|
||||
/// `plain174`: decoded LDPC codeword (174 bits, each 0 or 1).
|
||||
/// `uses_xor`: true for FT4/FT2 (apply XOR sequence), false for FT8.
|
||||
///
|
||||
/// Returns `None` if CRC check fails.
|
||||
pub(crate) fn verify_crc_and_build_message(
|
||||
plain174: &[u8; FTX_LDPC_N],
|
||||
uses_xor: bool,
|
||||
) -> Option<FtxMessage> {
|
||||
let mut a91 = [0u8; FTX_LDPC_K_BYTES];
|
||||
pack_bits(plain174, FTX_LDPC_K, &mut a91);
|
||||
|
||||
let a91_orig = a91;
|
||||
let crc_extracted = super::crc::ftx_extract_crc(&a91);
|
||||
a91[9] &= 0xF8;
|
||||
a91[10] = 0x00;
|
||||
let crc_calculated = super::crc::ftx_compute_crc(&a91, 96 - 14);
|
||||
|
||||
if crc_extracted != crc_calculated {
|
||||
return None;
|
||||
}
|
||||
|
||||
let a91 = a91_orig;
|
||||
|
||||
let mut message = FtxMessage {
|
||||
hash: crc_calculated,
|
||||
payload: [0; FTX_PAYLOAD_LENGTH_BYTES],
|
||||
};
|
||||
|
||||
if uses_xor {
|
||||
for i in 0..10 {
|
||||
message.payload[i] = a91[i] ^ FT4_XOR_SEQUENCE[i];
|
||||
}
|
||||
} else {
|
||||
message.payload[..10].copy_from_slice(&a91[..10]);
|
||||
}
|
||||
|
||||
Some(message)
|
||||
}
|
||||
|
||||
/// Normalize log-likelihoods.
|
||||
fn ftx_normalize_logl(log174: &mut [f32; FTX_LDPC_N]) {
|
||||
let mut sum = 0.0f32;
|
||||
let mut sum2 = 0.0f32;
|
||||
for &v in log174.iter() {
|
||||
sum += v;
|
||||
sum2 += v * v;
|
||||
}
|
||||
let inv_n = 1.0 / FTX_LDPC_N as f32;
|
||||
let variance = (sum2 - sum * sum * inv_n) * inv_n;
|
||||
if variance > 0.0 {
|
||||
let norm_factor = (24.0 / variance).sqrt();
|
||||
for v in log174.iter_mut() {
|
||||
*v *= norm_factor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pack bits into bytes (MSB first).
|
||||
pub fn pack_bits(bit_array: &[u8], num_bits: usize, packed: &mut [u8]) {
|
||||
let num_bytes = num_bits.div_ceil(8);
|
||||
for b in packed[..num_bytes].iter_mut() {
|
||||
*b = 0;
|
||||
}
|
||||
let mut mask: u8 = 0x80;
|
||||
let mut byte_idx = 0;
|
||||
for &bit in bit_array.iter().take(num_bits) {
|
||||
if bit != 0 {
|
||||
packed[byte_idx] |= mask;
|
||||
}
|
||||
mask >>= 1;
|
||||
if mask == 0 {
|
||||
mask = 0x80;
|
||||
byte_idx += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempt to decode a candidate. Returns decoded message or None.
|
||||
pub fn ftx_decode_candidate(
|
||||
wf: &Waterfall,
|
||||
cand: &Candidate,
|
||||
max_iterations: usize,
|
||||
) -> Option<FtxMessage> {
|
||||
let mut log174 = [0.0f32; FTX_LDPC_N];
|
||||
|
||||
#[cfg(feature = "ft2")]
|
||||
if wf.protocol == FtxProtocol::Ft2 {
|
||||
crate::ft2::ft2_extract_likelihood(wf, cand, &mut log174);
|
||||
} else if wf.protocol.uses_ft4_layout() {
|
||||
crate::ft4::ft4_extract_likelihood(wf, cand, &mut log174);
|
||||
} else {
|
||||
crate::ft8::ft8_extract_likelihood(wf, cand, &mut log174);
|
||||
}
|
||||
#[cfg(not(feature = "ft2"))]
|
||||
if wf.protocol.uses_ft4_layout() {
|
||||
crate::ft4::ft4_extract_likelihood(wf, cand, &mut log174);
|
||||
} else {
|
||||
crate::ft8::ft8_extract_likelihood(wf, cand, &mut log174);
|
||||
}
|
||||
|
||||
ftx_normalize_logl(&mut log174);
|
||||
|
||||
let mut plain174 = [0u8; FTX_LDPC_N];
|
||||
let errors = super::ldpc::bp_decode(&log174, max_iterations, &mut plain174);
|
||||
if errors > 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
verify_crc_and_build_message(&plain174, wf.protocol.uses_ft4_layout())
|
||||
}
|
||||
|
||||
/// Compute post-decode SNR.
|
||||
pub fn ftx_post_decode_snr(wf: &Waterfall, cand: &Candidate, message: &FtxMessage) -> f32 {
|
||||
let is_ft4 = wf.protocol.uses_ft4_layout();
|
||||
let nn = if is_ft4 { FT4_NN } else { FT8_NN };
|
||||
let num_tones = if is_ft4 { 4 } else { 8 };
|
||||
|
||||
let mut tones = [0u8; FT4_NN]; // FT4_NN >= FT8_NN
|
||||
if is_ft4 {
|
||||
crate::ft4::ft4_encode(&message.payload, &mut tones);
|
||||
} else {
|
||||
crate::ft8::ft8_encode(&message.payload, &mut tones);
|
||||
}
|
||||
|
||||
let base = get_cand_offset(wf, cand);
|
||||
let mut sum_snr = 0.0f32;
|
||||
let mut n_valid = 0;
|
||||
|
||||
for (sym, &tone) in tones.iter().enumerate().take(nn) {
|
||||
let block_abs = cand.time_offset as i32 + sym as i32;
|
||||
if block_abs < 0 || block_abs >= wf.num_blocks as i32 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let p_offset = base + sym * wf.block_stride;
|
||||
let sig_db = wf_mag_safe(wf, p_offset + tone as usize).mag;
|
||||
|
||||
let mut noise_min = 0.0f32;
|
||||
let mut found_noise = false;
|
||||
for t in 0..num_tones {
|
||||
if t == tone as usize {
|
||||
continue;
|
||||
}
|
||||
let db = wf_mag_safe(wf, p_offset + t).mag;
|
||||
if !found_noise || db < noise_min {
|
||||
noise_min = db;
|
||||
found_noise = true;
|
||||
}
|
||||
}
|
||||
|
||||
if found_noise {
|
||||
sum_snr += sig_db - noise_min;
|
||||
n_valid += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if n_valid == 0 {
|
||||
return cand.score as f32 * 0.5 - 29.0;
|
||||
}
|
||||
|
||||
let symbol_period = wf.protocol.symbol_period();
|
||||
let bw_correction = 10.0 * (2500.0 * symbol_period * wf.freq_osr as f32).log10();
|
||||
sum_snr / n_valid as f32 - bw_correction
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! Shared LDPC encoding functions used by all FTx protocols.
|
||||
|
||||
use super::constants::FTX_LDPC_GENERATOR;
|
||||
use super::protocol::{FTX_LDPC_K, FTX_LDPC_K_BYTES, FTX_LDPC_M, FTX_LDPC_N_BYTES};
|
||||
|
||||
/// Returns 1 if an odd number of bits are set in `x`, zero otherwise.
|
||||
pub(crate) fn parity8(x: u8) -> u8 {
|
||||
let x = x ^ (x >> 4);
|
||||
let x = x ^ (x >> 2);
|
||||
let x = x ^ (x >> 1);
|
||||
x & 1
|
||||
}
|
||||
|
||||
/// Encode via LDPC a 91-bit message and return a 174-bit codeword.
|
||||
///
|
||||
/// The generator matrix has dimensions (83, 91).
|
||||
/// The code is a (174, 91) regular LDPC code with column weight 3.
|
||||
///
|
||||
/// `message` must be at least `FTX_LDPC_K_BYTES` (12) bytes.
|
||||
/// `codeword` must be at least `FTX_LDPC_N_BYTES` (22) bytes.
|
||||
pub(crate) fn encode174(message: &[u8], codeword: &mut [u8]) {
|
||||
// Fill the codeword with message and zeros
|
||||
for j in 0..FTX_LDPC_N_BYTES {
|
||||
codeword[j] = if j < FTX_LDPC_K_BYTES { message[j] } else { 0 };
|
||||
}
|
||||
|
||||
// Compute the byte index and bit mask for the first checksum bit
|
||||
let mut col_mask: u8 = 0x80u8 >> (FTX_LDPC_K % 8);
|
||||
let mut col_idx: usize = FTX_LDPC_K_BYTES - 1;
|
||||
|
||||
// Compute the LDPC checksum bits and store them in codeword
|
||||
for gen_row in FTX_LDPC_GENERATOR.iter().take(FTX_LDPC_M) {
|
||||
let mut nsum: u8 = 0;
|
||||
for j in 0..FTX_LDPC_K_BYTES {
|
||||
nsum ^= parity8(message[j] & gen_row[j]);
|
||||
}
|
||||
|
||||
if !nsum.is_multiple_of(2) {
|
||||
codeword[col_idx] |= col_mask;
|
||||
}
|
||||
|
||||
col_mask >>= 1;
|
||||
if col_mask == 0 {
|
||||
col_mask = 0x80u8;
|
||||
col_idx += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode a packed 91-bit message into a 174-bit codeword (bit array).
|
||||
///
|
||||
/// Each element of the returned array is 0 or 1.
|
||||
/// Uses the same (174, 91) LDPC generator as `encode174`.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn encode174_to_bits(a91: &[u8; FTX_LDPC_K_BYTES]) -> [u8; super::protocol::FTX_LDPC_N] {
|
||||
use super::protocol::FTX_LDPC_N;
|
||||
let mut codeword_packed = [0u8; FTX_LDPC_N_BYTES];
|
||||
encode174(a91, &mut codeword_packed);
|
||||
|
||||
let mut bits = [0u8; FTX_LDPC_N];
|
||||
for i in 0..FTX_LDPC_N {
|
||||
bits[i] = (codeword_packed[i / 8] >> (7 - (i % 8))) & 0x01;
|
||||
}
|
||||
bits
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parity8_basic() {
|
||||
assert_eq!(parity8(0x00), 0); // 0 bits set
|
||||
assert_eq!(parity8(0x01), 1); // 1 bit set
|
||||
assert_eq!(parity8(0x03), 0); // 2 bits set
|
||||
assert_eq!(parity8(0x07), 1); // 3 bits set
|
||||
assert_eq!(parity8(0xFF), 0); // 8 bits set
|
||||
assert_eq!(parity8(0xFE), 1); // 7 bits set
|
||||
assert_eq!(parity8(0x80), 1); // 1 bit set
|
||||
assert_eq!(parity8(0xA5), 0); // 4 bits set (10100101)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode174_systematic() {
|
||||
// The first K_BYTES of the codeword should match the message
|
||||
let message = [0u8; FTX_LDPC_K_BYTES];
|
||||
let mut codeword = [0u8; FTX_LDPC_N_BYTES];
|
||||
encode174(&message, &mut codeword);
|
||||
|
||||
// All-zero message should produce all-zero codeword
|
||||
for byte in &codeword {
|
||||
assert_eq!(*byte, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode174_preserves_message() {
|
||||
// The codeword should start with the message bytes (systematic code).
|
||||
// Byte 11 shares bits between the last 3 message bits and the first
|
||||
// parity bits, so only check bytes 0..10 for exact match.
|
||||
let message: [u8; FTX_LDPC_K_BYTES] = [
|
||||
0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC, 0xDE, 0xF0, 0x11, 0x22, 0x33, 0x40,
|
||||
];
|
||||
let mut codeword = [0u8; FTX_LDPC_N_BYTES];
|
||||
encode174(&message, &mut codeword);
|
||||
|
||||
// First 11 bytes are pure message data
|
||||
for j in 0..(FTX_LDPC_K_BYTES - 1) {
|
||||
assert_eq!(codeword[j], message[j]);
|
||||
}
|
||||
// Byte 11: top 3 bits are message, lower 5 bits may have parity
|
||||
assert_eq!(codeword[11] & 0xE0, message[11] & 0xE0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode174_nonzero_parity() {
|
||||
// A non-zero message should produce non-zero parity bits
|
||||
let message: [u8; FTX_LDPC_K_BYTES] = [
|
||||
0xFF, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0xFF, 0xE0,
|
||||
];
|
||||
let mut codeword = [0u8; FTX_LDPC_N_BYTES];
|
||||
encode174(&message, &mut codeword);
|
||||
|
||||
// Parity portion should not be all zeros
|
||||
let parity_nonzero = codeword[FTX_LDPC_K_BYTES..FTX_LDPC_N_BYTES]
|
||||
.iter()
|
||||
.any(|&b| b != 0);
|
||||
assert!(
|
||||
parity_nonzero,
|
||||
"Parity bits should be non-zero for non-zero input"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode174_to_bits_all_zeros() {
|
||||
let a91 = [0u8; FTX_LDPC_K_BYTES];
|
||||
let cw = encode174_to_bits(&a91);
|
||||
for &b in &cw {
|
||||
assert_eq!(b, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! Pure Rust LDPC decoder for FTx protocols.
|
||||
//!
|
||||
//! This is a port of the sum-product and belief-propagation LDPC decoders
|
||||
//! from ft8_lib's `ldpc.c`. Given a 174-bit codeword as an array of
|
||||
//! log-likelihood ratios (log(P(x=0)/P(x=1))), returns a corrected 174-bit
|
||||
//! codeword. The last 87 bits are the systematic plain-text.
|
||||
|
||||
use super::constants::{FTX_LDPC_MN, FTX_LDPC_NM, FTX_LDPC_NUM_ROWS};
|
||||
use super::protocol::{FTX_LDPC_M, FTX_LDPC_N};
|
||||
|
||||
/// Fast rational approximation of `tanh(x)`, clamped at +/-4.97.
|
||||
pub(crate) fn fast_tanh(x: f32) -> f32 {
|
||||
if x < -4.97f32 {
|
||||
return -1.0f32;
|
||||
}
|
||||
if x > 4.97f32 {
|
||||
return 1.0f32;
|
||||
}
|
||||
let x2 = x * x;
|
||||
let a = x * (945.0f32 + x2 * (105.0f32 + x2));
|
||||
let b = 945.0f32 + x2 * (420.0f32 + x2 * 15.0f32);
|
||||
a / b
|
||||
}
|
||||
|
||||
/// Fast rational approximation of `atanh(x)`.
|
||||
pub(crate) fn fast_atanh(x: f32) -> f32 {
|
||||
let x2 = x * x;
|
||||
let a = x * (945.0f32 + x2 * (-735.0f32 + x2 * 64.0f32));
|
||||
let b = 945.0f32 + x2 * (-1050.0f32 + x2 * 225.0f32);
|
||||
a / b
|
||||
}
|
||||
|
||||
/// Count the number of LDPC parity errors in a 174-bit codeword.
|
||||
///
|
||||
/// Returns 0 if all parity checks pass (valid codeword).
|
||||
pub(crate) fn ldpc_check(codeword: &[u8; FTX_LDPC_N]) -> i32 {
|
||||
let mut errors = 0i32;
|
||||
for m in 0..FTX_LDPC_M {
|
||||
let mut x: u8 = 0;
|
||||
let num_rows = FTX_LDPC_NUM_ROWS[m] as usize;
|
||||
for i in 0..num_rows {
|
||||
x ^= codeword[FTX_LDPC_NM[m][i] as usize - 1];
|
||||
}
|
||||
if x != 0 {
|
||||
errors += 1;
|
||||
}
|
||||
}
|
||||
errors
|
||||
}
|
||||
|
||||
/// Sum-product LDPC decoder.
|
||||
///
|
||||
/// `codeword` contains 174 log-likelihood ratios (modified in place during
|
||||
/// decoding). `plain` receives the decoded 174-bit hard decisions (0 or 1).
|
||||
/// `max_iters` controls how many iterations to attempt.
|
||||
///
|
||||
/// Returns the number of remaining parity errors (0 = success).
|
||||
#[cfg(test)]
|
||||
pub fn ldpc_decode(
|
||||
codeword: &mut [f32; FTX_LDPC_N],
|
||||
max_iters: usize,
|
||||
plain: &mut [u8; FTX_LDPC_N],
|
||||
) -> i32 {
|
||||
// Flat arrays for m[][] and e[][] (~57 kB each, ~114 kB total on stack).
|
||||
let mut m_matrix = [0.0f32; FTX_LDPC_M * FTX_LDPC_N];
|
||||
let mut e_matrix = [0.0f32; FTX_LDPC_M * FTX_LDPC_N];
|
||||
|
||||
// Initialize m[][] with the channel LLRs.
|
||||
for j in 0..FTX_LDPC_M {
|
||||
m_matrix[j * FTX_LDPC_N..][..FTX_LDPC_N].copy_from_slice(codeword);
|
||||
}
|
||||
|
||||
let mut min_errors = FTX_LDPC_M as i32;
|
||||
|
||||
for _iter in 0..max_iters {
|
||||
// Update e[][] from m[][]
|
||||
for j in 0..FTX_LDPC_M {
|
||||
let num_rows = FTX_LDPC_NUM_ROWS[j] as usize;
|
||||
let m_row = j * FTX_LDPC_N;
|
||||
for ii1 in 0..num_rows {
|
||||
let i1 = FTX_LDPC_NM[j][ii1] as usize - 1;
|
||||
let mut a = 1.0f32;
|
||||
for ii2 in 0..num_rows {
|
||||
let i2 = FTX_LDPC_NM[j][ii2] as usize - 1;
|
||||
if i2 != i1 {
|
||||
a *= fast_tanh(-m_matrix[m_row + i2] / 2.0f32);
|
||||
}
|
||||
}
|
||||
e_matrix[j * FTX_LDPC_N + i1] = -2.0f32 * fast_atanh(a);
|
||||
}
|
||||
}
|
||||
|
||||
// Hard decisions
|
||||
for i in 0..FTX_LDPC_N {
|
||||
let mut l = codeword[i];
|
||||
for j in 0..3 {
|
||||
l += e_matrix[(FTX_LDPC_MN[i][j] as usize - 1) * FTX_LDPC_N + i];
|
||||
}
|
||||
plain[i] = if l > 0.0 { 1 } else { 0 };
|
||||
}
|
||||
|
||||
let errors = ldpc_check(plain);
|
||||
if errors < min_errors {
|
||||
min_errors = errors;
|
||||
if errors == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Update m[][] from e[][]
|
||||
for i in 0..FTX_LDPC_N {
|
||||
for ji1 in 0..3 {
|
||||
let j1 = FTX_LDPC_MN[i][ji1] as usize - 1;
|
||||
let mut l = codeword[i];
|
||||
for ji2 in 0..3 {
|
||||
if ji1 != ji2 {
|
||||
let j2 = FTX_LDPC_MN[i][ji2] as usize - 1;
|
||||
l += e_matrix[j2 * FTX_LDPC_N + i];
|
||||
}
|
||||
}
|
||||
m_matrix[j1 * FTX_LDPC_N + i] = l;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
min_errors
|
||||
}
|
||||
|
||||
/// Belief-propagation LDPC decoder.
|
||||
///
|
||||
/// `codeword` contains 174 log-likelihood ratios. `plain` receives the
|
||||
/// decoded 174-bit hard decisions (0 or 1). `max_iters` controls how many
|
||||
/// iterations to attempt.
|
||||
///
|
||||
/// Returns the number of remaining parity errors (0 = success).
|
||||
pub fn bp_decode(
|
||||
codeword: &[f32; FTX_LDPC_N],
|
||||
max_iters: usize,
|
||||
plain: &mut [u8; FTX_LDPC_N],
|
||||
) -> i32 {
|
||||
let mut tov = [[0.0f32; 3]; FTX_LDPC_N];
|
||||
let mut toc = [[0.0f32; 7]; FTX_LDPC_M];
|
||||
|
||||
let mut min_errors = FTX_LDPC_M as i32;
|
||||
|
||||
for _iter in 0..max_iters {
|
||||
// Hard decision guess (tov=0 in iter 0)
|
||||
let mut plain_sum = 0u32;
|
||||
for n in 0..FTX_LDPC_N {
|
||||
let sum = codeword[n] + tov[n][0] + tov[n][1] + tov[n][2];
|
||||
plain[n] = if sum > 0.0 { 1 } else { 0 };
|
||||
plain_sum += plain[n] as u32;
|
||||
}
|
||||
|
||||
if plain_sum == 0 {
|
||||
// Message converged to all-zeros, which is prohibited.
|
||||
break;
|
||||
}
|
||||
|
||||
let errors = ldpc_check(plain);
|
||||
if errors < min_errors {
|
||||
min_errors = errors;
|
||||
if errors == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Send messages from bits to check nodes
|
||||
for m in 0..FTX_LDPC_M {
|
||||
let num_rows = FTX_LDPC_NUM_ROWS[m] as usize;
|
||||
for n_idx in 0..num_rows {
|
||||
let n = FTX_LDPC_NM[m][n_idx] as usize - 1;
|
||||
let mut tnm = codeword[n];
|
||||
for m_idx in 0..3 {
|
||||
if (FTX_LDPC_MN[n][m_idx] as usize - 1) != m {
|
||||
tnm += tov[n][m_idx];
|
||||
}
|
||||
}
|
||||
toc[m][n_idx] = fast_tanh(-tnm / 2.0);
|
||||
}
|
||||
}
|
||||
|
||||
// Send messages from check nodes to variable nodes
|
||||
for n in 0..FTX_LDPC_N {
|
||||
for m_idx in 0..3 {
|
||||
let m = FTX_LDPC_MN[n][m_idx] as usize - 1;
|
||||
let num_rows = FTX_LDPC_NUM_ROWS[m] as usize;
|
||||
let mut tmn = 1.0f32;
|
||||
for n_idx in 0..num_rows {
|
||||
if (FTX_LDPC_NM[m][n_idx] as usize - 1) != n {
|
||||
tmn *= toc[m][n_idx];
|
||||
}
|
||||
}
|
||||
tov[n][m_idx] = -2.0 * fast_atanh(tmn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
min_errors
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_fast_tanh_clamp() {
|
||||
assert_eq!(fast_tanh(-5.0), -1.0);
|
||||
assert_eq!(fast_tanh(5.0), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fast_tanh_zero() {
|
||||
assert!((fast_tanh(0.0)).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fast_tanh_approximation() {
|
||||
for &x in &[-3.0f32, -1.0, -0.5, 0.5, 1.0, 3.0] {
|
||||
let approx = fast_tanh(x);
|
||||
let exact = x.tanh();
|
||||
assert!(
|
||||
(approx - exact).abs() < 0.01,
|
||||
"fast_tanh({}) = {}, expected ~{}",
|
||||
x,
|
||||
approx,
|
||||
exact
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fast_atanh_zero() {
|
||||
assert!((fast_atanh(0.0)).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fast_atanh_approximation() {
|
||||
for &x in &[-0.5f32, -0.25, 0.25, 0.5] {
|
||||
let approx = fast_atanh(x);
|
||||
let exact = x.atanh();
|
||||
assert!(
|
||||
(approx - exact).abs() < 0.05,
|
||||
"fast_atanh({}) = {}, expected ~{}",
|
||||
x,
|
||||
approx,
|
||||
exact
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ldpc_check_all_zeros() {
|
||||
// All-zero codeword should pass all parity checks.
|
||||
let codeword = [0u8; FTX_LDPC_N];
|
||||
assert_eq!(ldpc_check(&codeword), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ldpc_check_single_bit_error() {
|
||||
// Flipping one bit should cause parity errors.
|
||||
let mut codeword = [0u8; FTX_LDPC_N];
|
||||
codeword[0] = 1;
|
||||
assert!(ldpc_check(&codeword) > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ldpc_decode_all_zeros() {
|
||||
// Negative LLRs → hard decision 0 for all bits.
|
||||
// The all-zeros codeword satisfies all LDPC parity checks.
|
||||
let mut codeword = [-10.0f32; FTX_LDPC_N];
|
||||
let mut plain = [0u8; FTX_LDPC_N];
|
||||
let errors = ldpc_decode(&mut codeword, 20, &mut plain);
|
||||
assert_eq!(errors, 0);
|
||||
assert!(plain.iter().all(|&b| b == 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bp_decode_all_ones() {
|
||||
// Positive LLRs → hard decision 1 for all bits.
|
||||
// All-ones is not a valid codeword, so bp_decode should report errors.
|
||||
let codeword = [10.0f32; FTX_LDPC_N];
|
||||
let mut plain = [0u8; FTX_LDPC_N];
|
||||
let errors = bp_decode(&codeword, 20, &mut plain);
|
||||
assert!(errors > 0);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,17 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! Common types, constants, and shared functions used across all FTx protocols.
|
||||
|
||||
pub mod callsign_hash;
|
||||
pub mod constants;
|
||||
pub mod crc;
|
||||
pub mod decode;
|
||||
pub mod encode;
|
||||
pub mod ldpc;
|
||||
pub mod message;
|
||||
pub mod monitor;
|
||||
pub mod osd;
|
||||
pub mod protocol;
|
||||
pub mod text;
|
||||
@@ -0,0 +1,284 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! Windowed FFT waterfall/spectrogram engine for FTx decoding.
|
||||
//!
|
||||
//! Replaces `monitor.c` from ft8_lib, using `realfft`/`rustfft` instead of KissFFT.
|
||||
|
||||
use num_complex::Complex32;
|
||||
use realfft::RealFftPlanner;
|
||||
|
||||
use super::protocol::FtxProtocol;
|
||||
|
||||
/// Waterfall element storing magnitude (dB), phase (radians), and raw complex components.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct WfElem {
|
||||
pub mag: f32,
|
||||
pub phase: f32,
|
||||
pub re: f32,
|
||||
pub im: f32,
|
||||
}
|
||||
|
||||
impl WfElem {
|
||||
pub fn mag_int(self) -> i32 {
|
||||
(2.0 * (self.mag + 120.0)) as i32
|
||||
}
|
||||
}
|
||||
|
||||
/// Waterfall data collected during a message slot.
|
||||
pub struct Waterfall {
|
||||
pub max_blocks: usize,
|
||||
pub num_blocks: usize,
|
||||
pub num_bins: usize,
|
||||
pub time_osr: usize,
|
||||
pub freq_osr: usize,
|
||||
pub mag: Vec<WfElem>,
|
||||
pub block_stride: usize,
|
||||
pub protocol: FtxProtocol,
|
||||
}
|
||||
|
||||
impl Waterfall {
|
||||
pub fn new(
|
||||
max_blocks: usize,
|
||||
num_bins: usize,
|
||||
time_osr: usize,
|
||||
freq_osr: usize,
|
||||
protocol: FtxProtocol,
|
||||
) -> Self {
|
||||
let block_stride = time_osr * freq_osr * num_bins;
|
||||
let mag = vec![WfElem::default(); max_blocks * block_stride];
|
||||
Self {
|
||||
max_blocks,
|
||||
num_blocks: 0,
|
||||
num_bins,
|
||||
time_osr,
|
||||
freq_osr,
|
||||
mag,
|
||||
block_stride,
|
||||
protocol,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
self.num_blocks = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Monitor configuration.
|
||||
pub struct MonitorConfig {
|
||||
pub f_min: f32,
|
||||
pub f_max: f32,
|
||||
pub sample_rate: i32,
|
||||
pub time_osr: i32,
|
||||
pub freq_osr: i32,
|
||||
pub protocol: FtxProtocol,
|
||||
}
|
||||
|
||||
/// FTx monitor that manages DSP processing and prepares waterfall data.
|
||||
pub struct Monitor {
|
||||
pub symbol_period: f32,
|
||||
pub min_bin: usize,
|
||||
pub max_bin: usize,
|
||||
pub block_size: usize,
|
||||
pub subblock_size: usize,
|
||||
pub nfft: usize,
|
||||
pub fft_norm: f32,
|
||||
window: Vec<f32>,
|
||||
last_frame: Vec<f32>,
|
||||
pub wf: Waterfall,
|
||||
pub max_mag: f32,
|
||||
// FFT planners/scratch
|
||||
fft_scratch: Vec<Complex32>,
|
||||
fft_output: Vec<Complex32>,
|
||||
fft_input: Vec<f32>,
|
||||
real_fft: std::sync::Arc<dyn realfft::RealToComplex<f32>>,
|
||||
}
|
||||
|
||||
fn hann_i(i: usize, n: usize) -> f32 {
|
||||
let x = (std::f32::consts::PI * i as f32 / n as f32).sin();
|
||||
x * x
|
||||
}
|
||||
|
||||
impl Monitor {
|
||||
pub fn new(cfg: &MonitorConfig) -> Self {
|
||||
let symbol_period = cfg.protocol.symbol_period();
|
||||
let slot_time = cfg.protocol.slot_time();
|
||||
|
||||
let block_size = (cfg.sample_rate as f32 * symbol_period) as usize;
|
||||
let subblock_size = block_size / cfg.time_osr as usize;
|
||||
let nfft = block_size * cfg.freq_osr as usize;
|
||||
let fft_norm = 2.0 / nfft as f32;
|
||||
|
||||
let window: Vec<f32> = (0..nfft).map(|i| fft_norm * hann_i(i, nfft)).collect();
|
||||
let last_frame = vec![0.0f32; nfft];
|
||||
|
||||
let min_bin = (cfg.f_min * symbol_period) as usize;
|
||||
let max_bin = (cfg.f_max * symbol_period) as usize + 1;
|
||||
let num_bins = max_bin - min_bin;
|
||||
let max_blocks = (slot_time / symbol_period) as usize;
|
||||
|
||||
let wf = Waterfall::new(
|
||||
max_blocks,
|
||||
num_bins,
|
||||
cfg.time_osr as usize,
|
||||
cfg.freq_osr as usize,
|
||||
cfg.protocol,
|
||||
);
|
||||
|
||||
let mut real_planner = RealFftPlanner::<f32>::new();
|
||||
let real_fft = real_planner.plan_fft_forward(nfft);
|
||||
let fft_scratch = real_fft.make_scratch_vec();
|
||||
let fft_output = real_fft.make_output_vec();
|
||||
let fft_input = real_fft.make_input_vec();
|
||||
|
||||
Self {
|
||||
symbol_period,
|
||||
min_bin,
|
||||
max_bin,
|
||||
block_size,
|
||||
subblock_size,
|
||||
nfft,
|
||||
fft_norm,
|
||||
window,
|
||||
last_frame,
|
||||
wf,
|
||||
max_mag: -120.0,
|
||||
fft_scratch,
|
||||
fft_output,
|
||||
fft_input,
|
||||
real_fft,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
self.wf.reset();
|
||||
self.max_mag = -120.0;
|
||||
self.last_frame.fill(0.0);
|
||||
}
|
||||
|
||||
/// Process one block of audio samples and update the waterfall.
|
||||
pub fn process(&mut self, frame: &[f32]) {
|
||||
if self.wf.num_blocks >= self.wf.max_blocks {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut offset = self.wf.num_blocks * self.wf.block_stride;
|
||||
let mut frame_pos = 0;
|
||||
|
||||
for _time_sub in 0..self.wf.time_osr {
|
||||
// Shift new data into analysis frame
|
||||
let shift = self.nfft - self.subblock_size;
|
||||
self.last_frame
|
||||
.copy_within(self.subblock_size..self.nfft, 0);
|
||||
for pos in shift..self.nfft {
|
||||
self.last_frame[pos] = if frame_pos < frame.len() {
|
||||
frame[frame_pos]
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
frame_pos += 1;
|
||||
}
|
||||
|
||||
// Windowed FFT
|
||||
self.fft_input
|
||||
.iter_mut()
|
||||
.zip(self.window.iter().zip(self.last_frame.iter()))
|
||||
.for_each(|(dst, (w, f))| *dst = w * f);
|
||||
self.real_fft
|
||||
.process_with_scratch(
|
||||
&mut self.fft_input,
|
||||
&mut self.fft_output,
|
||||
&mut self.fft_scratch,
|
||||
)
|
||||
.expect("FFT process failed");
|
||||
|
||||
// Extract magnitude and phase for each frequency sub-bin
|
||||
for freq_sub in 0..self.wf.freq_osr {
|
||||
for bin in self.min_bin..self.max_bin {
|
||||
let src_bin = bin * self.wf.freq_osr + freq_sub;
|
||||
if src_bin < self.fft_output.len() {
|
||||
let c = self.fft_output[src_bin];
|
||||
let mag2 = c.re * c.re + c.im * c.im;
|
||||
let db = 10.0 * (1e-12_f32 + mag2).log10();
|
||||
let phase = c.im.atan2(c.re);
|
||||
|
||||
if offset < self.wf.mag.len() {
|
||||
self.wf.mag[offset] = WfElem {
|
||||
mag: db,
|
||||
phase,
|
||||
re: c.re,
|
||||
im: c.im,
|
||||
};
|
||||
}
|
||||
offset += 1;
|
||||
|
||||
if db > self.max_mag {
|
||||
self.max_mag = db;
|
||||
}
|
||||
} else {
|
||||
if offset < self.wf.mag.len() {
|
||||
self.wf.mag[offset] = WfElem {
|
||||
mag: -120.0,
|
||||
phase: 0.0,
|
||||
re: 0.0,
|
||||
im: 0.0,
|
||||
};
|
||||
}
|
||||
offset += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.wf.num_blocks += 1;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn monitor_block_size_ft8() {
|
||||
let cfg = MonitorConfig {
|
||||
f_min: 200.0,
|
||||
f_max: 3000.0,
|
||||
sample_rate: 12000,
|
||||
time_osr: 2,
|
||||
freq_osr: 2,
|
||||
protocol: FtxProtocol::Ft8,
|
||||
};
|
||||
let mon = Monitor::new(&cfg);
|
||||
assert_eq!(mon.block_size, 1920); // 12000 * 0.160
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monitor_block_size_ft4() {
|
||||
let cfg = MonitorConfig {
|
||||
f_min: 200.0,
|
||||
f_max: 3000.0,
|
||||
sample_rate: 12000,
|
||||
time_osr: 2,
|
||||
freq_osr: 2,
|
||||
protocol: FtxProtocol::Ft4,
|
||||
};
|
||||
let mon = Monitor::new(&cfg);
|
||||
assert_eq!(mon.block_size, 576); // 12000 * 0.048
|
||||
}
|
||||
|
||||
#[cfg(feature = "ft2")]
|
||||
#[test]
|
||||
fn monitor_block_size_ft2() {
|
||||
let cfg = MonitorConfig {
|
||||
f_min: 200.0,
|
||||
f_max: 5000.0,
|
||||
sample_rate: 12000,
|
||||
time_osr: 8,
|
||||
freq_osr: 4,
|
||||
protocol: FtxProtocol::Ft2,
|
||||
};
|
||||
let mon = Monitor::new(&cfg);
|
||||
assert_eq!(mon.block_size, 288); // 12000 * 0.024
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,922 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! OSD-1/OSD-2 CRC-guided bit-flip decoder for the (174,91) LDPC code.
|
||||
//!
|
||||
//! This is a port of `ft2_ldpc.c` which implements Ordered Statistics Decoding
|
||||
//! with configurable depth (ndeep 0-6). The decoder first runs iterative
|
||||
//! belief-propagation (BP), then falls back to OSD refinement using the
|
||||
//! accumulated LLR sums from BP iterations.
|
||||
//!
|
||||
//! The OSD algorithm works by:
|
||||
//! 1. Sorting codeword bits by LLR reliability
|
||||
//! 2. Gaussian elimination to put the generator matrix in systematic form
|
||||
//! (with respect to the most reliable bits)
|
||||
//! 3. Exhaustive search over bit-flip patterns of increasing weight
|
||||
//! 4. Pattern hashing (OSD-2) to efficiently search two-bit-flip corrections
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use super::constants::{FTX_LDPC_GENERATOR, FTX_LDPC_MN, FTX_LDPC_NM, FTX_LDPC_NUM_ROWS};
|
||||
use super::crc::{ftx_compute_crc, ftx_extract_crc};
|
||||
use super::decode::pack_bits;
|
||||
use super::encode::parity8;
|
||||
use super::ldpc::ldpc_check;
|
||||
use super::protocol::{FTX_LDPC_K, FTX_LDPC_K_BYTES, FTX_LDPC_M, FTX_LDPC_N};
|
||||
|
||||
/// Piecewise linear approximation of `atanh(x)` used in BP message passing.
|
||||
fn platanh(x: f32) -> f32 {
|
||||
let isign: f32 = if x < 0.0 { -1.0 } else { 1.0 };
|
||||
let z = x.abs();
|
||||
|
||||
if z <= 0.664 {
|
||||
return x / 0.83;
|
||||
}
|
||||
if z <= 0.9217 {
|
||||
return isign * ((z - 0.4064) / 0.322);
|
||||
}
|
||||
if z <= 0.9951 {
|
||||
return isign * ((z - 0.8378) / 0.0524);
|
||||
}
|
||||
if z <= 0.9998 {
|
||||
return isign * ((z - 0.9914) / 0.0012);
|
||||
}
|
||||
isign * 7.0
|
||||
}
|
||||
|
||||
/// Check CRC of a 91-bit message (in bit array form).
|
||||
fn check_crc91(plain91: &[u8]) -> bool {
|
||||
let mut a91 = [0u8; FTX_LDPC_K_BYTES];
|
||||
pack_bits(plain91, FTX_LDPC_K, &mut a91);
|
||||
let crc_extracted = ftx_extract_crc(&a91);
|
||||
a91[9] &= 0xF8;
|
||||
a91[10] = 0x00;
|
||||
let crc_calculated = ftx_compute_crc(&a91, 96 - 14);
|
||||
crc_extracted == crc_calculated
|
||||
}
|
||||
|
||||
/// Encode a 91-bit message (bit array) into a 174-bit codeword without CRC computation.
|
||||
fn encode174_91_nocrc_bits(message91: &[u8], codeword: &mut [u8; FTX_LDPC_N]) {
|
||||
let mut packed = [0u8; FTX_LDPC_K_BYTES];
|
||||
pack_bits(message91, FTX_LDPC_K, &mut packed);
|
||||
|
||||
// Systematic bits
|
||||
for i in 0..FTX_LDPC_K {
|
||||
codeword[i] = message91[i] & 0x01;
|
||||
}
|
||||
|
||||
// Parity bits from generator matrix
|
||||
for i in 0..FTX_LDPC_M {
|
||||
let mut nsum: u8 = 0;
|
||||
for j in 0..FTX_LDPC_K_BYTES {
|
||||
nsum ^= parity8(packed[j] & FTX_LDPC_GENERATOR[i][j]);
|
||||
}
|
||||
codeword[FTX_LDPC_K + i] = nsum & 0x01;
|
||||
}
|
||||
}
|
||||
|
||||
/// Matrix-vector multiply for re-encoding in OSD.
|
||||
fn mrbencode91(me: &[u8], codeword: &mut [u8], g2: &[u8], n: usize, k: usize) {
|
||||
codeword[..n].fill(0);
|
||||
for i in 0..k {
|
||||
if me[i] == 0 {
|
||||
continue;
|
||||
}
|
||||
codeword[..n]
|
||||
.iter_mut()
|
||||
.enumerate()
|
||||
.for_each(|(j, c)| *c ^= g2[j * k + i]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate next bit-flip pattern of given order.
|
||||
fn nextpat91(mi: &mut [u8], k: usize, iorder: usize, iflag: &mut i32) {
|
||||
let mut ind: i32 = -1;
|
||||
for i in 0..k.saturating_sub(1) {
|
||||
if mi[i] == 0 && mi[i + 1] == 1 {
|
||||
ind = i as i32;
|
||||
}
|
||||
}
|
||||
|
||||
if ind < 0 {
|
||||
*iflag = -1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Build new pattern in-place: zero out after ind, set the swap, pack remaining 1s at end
|
||||
let ind_u = ind as usize;
|
||||
mi[(ind_u + 1)..k].fill(0);
|
||||
mi[ind_u] = 1;
|
||||
|
||||
let mut nz = iorder as i32;
|
||||
for &v in mi.iter().take(k) {
|
||||
nz -= v as i32;
|
||||
}
|
||||
if nz > 0 {
|
||||
mi[(k - nz as usize)..k].fill(1);
|
||||
}
|
||||
|
||||
*iflag = -1;
|
||||
for (i, &v) in mi.iter().enumerate().take(k) {
|
||||
if v == 1 {
|
||||
*iflag = i as i32;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pattern hash table for OSD-2 optimization.
|
||||
struct OsdBox {
|
||||
head: Vec<i32>,
|
||||
next: Vec<i32>,
|
||||
pairs: Vec<[i32; 2]>,
|
||||
capacity: usize,
|
||||
count: usize,
|
||||
last_pattern: i32,
|
||||
next_index: i32,
|
||||
}
|
||||
|
||||
impl OsdBox {
|
||||
fn new(ntau: usize) -> Option<Self> {
|
||||
let size = 1 << ntau;
|
||||
let capacity = 5000;
|
||||
Some(Self {
|
||||
head: vec![-1; size],
|
||||
next: vec![-1; capacity],
|
||||
pairs: vec![[-1, -1]; capacity],
|
||||
capacity,
|
||||
count: 0,
|
||||
last_pattern: -1,
|
||||
next_index: -1,
|
||||
})
|
||||
}
|
||||
|
||||
fn boxit(&mut self, e2: &[u8], ntau: usize, i1: i32, i2: i32) {
|
||||
if self.count >= self.capacity {
|
||||
return;
|
||||
}
|
||||
let idx = self.count;
|
||||
self.count += 1;
|
||||
self.pairs[idx] = [i1, i2];
|
||||
|
||||
let ipat = pattern_hash(e2, ntau);
|
||||
let ip = self.head[ipat];
|
||||
if ip == -1 {
|
||||
self.head[ipat] = idx as i32;
|
||||
} else {
|
||||
let mut cur = ip;
|
||||
while self.next[cur as usize] != -1 {
|
||||
cur = self.next[cur as usize];
|
||||
}
|
||||
self.next[cur as usize] = idx as i32;
|
||||
}
|
||||
}
|
||||
|
||||
fn fetchit(&mut self, e2: &[u8], ntau: usize) -> (i32, i32) {
|
||||
let ipat = pattern_hash(e2, ntau);
|
||||
let index = self.head[ipat];
|
||||
|
||||
if self.last_pattern != ipat as i32 && index >= 0 {
|
||||
let i1 = self.pairs[index as usize][0];
|
||||
let i2 = self.pairs[index as usize][1];
|
||||
self.next_index = self.next[index as usize];
|
||||
self.last_pattern = ipat as i32;
|
||||
(i1, i2)
|
||||
} else if self.last_pattern == ipat as i32 && self.next_index >= 0 {
|
||||
let ni = self.next_index as usize;
|
||||
let i1 = self.pairs[ni][0];
|
||||
let i2 = self.pairs[ni][1];
|
||||
self.next_index = self.next[ni];
|
||||
(i1, i2)
|
||||
} else {
|
||||
self.next_index = -1;
|
||||
self.last_pattern = ipat as i32;
|
||||
(-1, -1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute hash of a bit pattern for OSD-2 lookup.
|
||||
fn pattern_hash(e2: &[u8], ntau: usize) -> usize {
|
||||
let mut ipat = 0usize;
|
||||
for (i, &v) in e2.iter().enumerate().take(ntau) {
|
||||
if v != 0 {
|
||||
ipat |= 1 << (ntau - i - 1);
|
||||
}
|
||||
}
|
||||
ipat
|
||||
}
|
||||
|
||||
/// Ordered Statistics Decoding with configurable depth.
|
||||
///
|
||||
/// `llr`: log-likelihood ratios for 174 bits (modified internally).
|
||||
/// `k`: number of systematic bits (91).
|
||||
/// `apmask`: a priori mask (which bits are known).
|
||||
/// `ndeep`: search depth (0-6).
|
||||
/// `message91`: output 91-bit message.
|
||||
/// `cw`: output 174-bit codeword.
|
||||
/// `nhardmin`: output minimum hard errors.
|
||||
/// `dmin`: output minimum distance.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn osd174_91(
|
||||
llr: &mut [f32; FTX_LDPC_N],
|
||||
k: usize,
|
||||
apmask: &[u8; FTX_LDPC_N],
|
||||
ndeep: usize,
|
||||
message91: &mut [u8; FTX_LDPC_K],
|
||||
cw: &mut [u8; FTX_LDPC_N],
|
||||
nhardmin: &mut i32,
|
||||
dmin: &mut f32,
|
||||
) {
|
||||
let n = FTX_LDPC_N;
|
||||
let ndeep = ndeep.min(6);
|
||||
|
||||
// Cached per-bit generator matrix (each row i generates codeword from
|
||||
// unit vector e_i)
|
||||
let gen = generator_matrix();
|
||||
|
||||
// Stack-allocated working buffers (k=91, n=174, n-k=83).
|
||||
let mut genmrb = [0u8; FTX_LDPC_K * FTX_LDPC_N];
|
||||
let mut g2 = [0u8; FTX_LDPC_N * FTX_LDPC_K];
|
||||
let mut m0 = [0u8; FTX_LDPC_K];
|
||||
let mut me = [0u8; FTX_LDPC_K];
|
||||
let mut mi = [0u8; FTX_LDPC_K];
|
||||
let mut misub = [0u8; FTX_LDPC_K];
|
||||
let mut e2sub = [0u8; FTX_LDPC_M];
|
||||
let mut e2 = [0u8; FTX_LDPC_M];
|
||||
let mut ui = [0u8; FTX_LDPC_M];
|
||||
let mut r2pat = [0u8; FTX_LDPC_M];
|
||||
let mut hdec = [0u8; FTX_LDPC_N];
|
||||
let mut c0 = [0u8; FTX_LDPC_N];
|
||||
let mut ce = [0u8; FTX_LDPC_N];
|
||||
let mut nxor = [0u8; FTX_LDPC_N];
|
||||
let mut apmaskr = [0u8; FTX_LDPC_N];
|
||||
let mut rx = [0.0f32; FTX_LDPC_N];
|
||||
let mut absrx = [0.0f32; FTX_LDPC_N];
|
||||
let mut indices = [0usize; FTX_LDPC_N];
|
||||
|
||||
// Sort bits by reliability (descending)
|
||||
let mut rel_indices = [0usize; FTX_LDPC_N];
|
||||
let mut rel_abs = [0.0f32; FTX_LDPC_N];
|
||||
for i in 0..n {
|
||||
rel_indices[i] = i;
|
||||
rel_abs[i] = llr[i].abs();
|
||||
}
|
||||
rel_indices[..n].sort_by(|&a, &b| {
|
||||
rel_abs[b]
|
||||
.partial_cmp(&rel_abs[a])
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
|
||||
for i in 0..n {
|
||||
rx[i] = llr[i];
|
||||
apmaskr[i] = apmask[i];
|
||||
hdec[i] = if rx[i] >= 0.0 { 1 } else { 0 };
|
||||
absrx[i] = rx[i].abs();
|
||||
}
|
||||
|
||||
// Reorder by reliability
|
||||
for i in 0..n {
|
||||
indices[i] = rel_indices[i];
|
||||
for row in 0..k {
|
||||
genmrb[row * n + i] = gen[row][indices[i]];
|
||||
}
|
||||
}
|
||||
|
||||
// Gaussian elimination to systematic form
|
||||
for id in 0..k {
|
||||
let max_col = (k + 20).min(n);
|
||||
for col in id..max_col {
|
||||
if genmrb[id * n + col] == 0 {
|
||||
continue;
|
||||
}
|
||||
// Swap columns id and col
|
||||
if col != id {
|
||||
for row in 0..k {
|
||||
genmrb.swap(row * n + id, row * n + col);
|
||||
}
|
||||
indices.swap(id, col);
|
||||
}
|
||||
// Eliminate column id from all other rows
|
||||
for row in 0..k {
|
||||
if row != id && genmrb[row * n + id] == 1 {
|
||||
for c in 0..n {
|
||||
genmrb[row * n + c] ^= genmrb[id * n + c];
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Transpose to column-major g2
|
||||
for row in 0..k {
|
||||
for col in 0..n {
|
||||
g2[col * k + row] = genmrb[row * n + col];
|
||||
}
|
||||
}
|
||||
|
||||
// Reorder LLRs and hard decisions by reliability
|
||||
for i in 0..n {
|
||||
hdec[i] = if rx[indices[i]] >= 0.0 { 1 } else { 0 };
|
||||
absrx[i] = rx[indices[i]].abs();
|
||||
rx[i] = llr[indices[i]];
|
||||
apmaskr[i] = apmask[indices[i]];
|
||||
}
|
||||
m0[..k].copy_from_slice(&hdec[..k]);
|
||||
|
||||
// Initial encode
|
||||
mrbencode91(&m0, &mut c0, &g2, n, k);
|
||||
for i in 0..n {
|
||||
nxor[i] = c0[i] ^ hdec[i];
|
||||
}
|
||||
*nhardmin = 0;
|
||||
*dmin = 0.0;
|
||||
for i in 0..n {
|
||||
*nhardmin += nxor[i] as i32;
|
||||
if nxor[i] != 0 {
|
||||
*dmin += absrx[i];
|
||||
}
|
||||
}
|
||||
cw.copy_from_slice(&c0[..n]);
|
||||
|
||||
if ndeep == 0 {
|
||||
reorder_result(cw, &indices, message91, nhardmin, dmin, llr);
|
||||
return;
|
||||
}
|
||||
|
||||
// Configure search parameters based on depth
|
||||
let (nord, npre1, npre2, nt, ntheta, ntau) = match ndeep {
|
||||
1 => (1, 0, 0, 40, 12, 0),
|
||||
2 => (1, 1, 0, 40, 10, 0),
|
||||
3 => (1, 1, 1, 40, 12, 14),
|
||||
4 => (2, 1, 1, 40, 12, 17),
|
||||
5 => (3, 1, 1, 40, 12, 15),
|
||||
_ => (4, 1, 1, 95, 12, 15),
|
||||
};
|
||||
|
||||
// OSD-1: exhaustive search over bit patterns of increasing order
|
||||
for iorder in 1..=nord {
|
||||
misub.iter_mut().for_each(|v| *v = 0);
|
||||
misub[(k - iorder)..k].fill(1);
|
||||
let mut iflag = (k - iorder) as i32;
|
||||
|
||||
while iflag >= 0 {
|
||||
let iend = if iorder == nord && npre1 == 0 {
|
||||
iflag as usize
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let mut d1 = 0.0f32;
|
||||
|
||||
let mut n1 = iflag;
|
||||
while n1 >= iend as i32 {
|
||||
mi[..k].copy_from_slice(&misub[..k]);
|
||||
mi[n1 as usize] = 1;
|
||||
|
||||
// Check if any masked bit would be flipped
|
||||
let masked = (0..k).any(|i| apmaskr[i] != 0 && mi[i] != 0);
|
||||
if masked {
|
||||
n1 -= 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
for i in 0..k {
|
||||
me[i] = m0[i] ^ mi[i];
|
||||
}
|
||||
|
||||
if n1 == iflag {
|
||||
mrbencode91(&me, &mut ce, &g2, n, k);
|
||||
for i in 0..(n - k) {
|
||||
e2sub[i] = ce[k + i] ^ hdec[k + i];
|
||||
e2[i] = e2sub[i];
|
||||
}
|
||||
let mut nd1kpt = 1;
|
||||
for &v in e2sub.iter().take(nt.min(n - k)) {
|
||||
nd1kpt += v as i32;
|
||||
}
|
||||
d1 = 0.0;
|
||||
for i in 0..k {
|
||||
if (me[i] ^ hdec[i]) != 0 {
|
||||
d1 += absrx[i];
|
||||
}
|
||||
}
|
||||
if nd1kpt <= ntheta {
|
||||
let mut dd = d1;
|
||||
for i in 0..(n - k) {
|
||||
if e2sub[i] != 0 {
|
||||
dd += absrx[k + i];
|
||||
}
|
||||
}
|
||||
if dd < *dmin {
|
||||
*dmin = dd;
|
||||
cw[..n].copy_from_slice(&ce[..n]);
|
||||
*nhardmin = 0;
|
||||
for i in 0..n {
|
||||
*nhardmin += (ce[i] ^ hdec[i]) as i32;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for i in 0..(n - k) {
|
||||
e2[i] = e2sub[i] ^ g2[(k + i) * k + n1 as usize];
|
||||
}
|
||||
let mut nd1kpt = 2;
|
||||
for &v in e2.iter().take(nt.min(n - k)) {
|
||||
nd1kpt += v as i32;
|
||||
}
|
||||
if nd1kpt <= ntheta {
|
||||
mrbencode91(&me, &mut ce, &g2, n, k);
|
||||
let mut dd = d1
|
||||
+ if (ce[n1 as usize] ^ hdec[n1 as usize]) != 0 {
|
||||
absrx[n1 as usize]
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
for i in 0..(n - k) {
|
||||
if e2[i] != 0 {
|
||||
dd += absrx[k + i];
|
||||
}
|
||||
}
|
||||
if dd < *dmin {
|
||||
*dmin = dd;
|
||||
cw[..n].copy_from_slice(&ce[..n]);
|
||||
*nhardmin = 0;
|
||||
for i in 0..n {
|
||||
*nhardmin += (ce[i] ^ hdec[i]) as i32;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
n1 -= 1;
|
||||
}
|
||||
nextpat91(&mut misub, k, iorder, &mut iflag);
|
||||
}
|
||||
}
|
||||
|
||||
// OSD-2: pattern-hashed two-bit-flip search
|
||||
if npre2 == 1 {
|
||||
if let Some(mut osd_box) = OsdBox::new(ntau) {
|
||||
// Build hash table of all column pairs
|
||||
for i1 in (0..k as i32).rev() {
|
||||
for i2 in (0..i1).rev() {
|
||||
for i in 0..ntau {
|
||||
mi[i] = g2[(k + i) * k + i1 as usize] ^ g2[(k + i) * k + i2 as usize];
|
||||
}
|
||||
osd_box.boxit(&mi, ntau, i1, i2);
|
||||
}
|
||||
}
|
||||
|
||||
// Search using base patterns
|
||||
misub.iter_mut().for_each(|v| *v = 0);
|
||||
misub[(k - nord)..k].fill(1);
|
||||
let mut iflag = (k - nord) as i32;
|
||||
|
||||
while iflag >= 0 {
|
||||
for i in 0..k {
|
||||
me[i] = m0[i] ^ misub[i];
|
||||
}
|
||||
mrbencode91(&me, &mut ce, &g2, n, k);
|
||||
for i in 0..(n - k) {
|
||||
e2sub[i] = ce[k + i] ^ hdec[k + i];
|
||||
}
|
||||
|
||||
for i2 in 0..=ntau {
|
||||
ui.iter_mut().for_each(|v| *v = 0);
|
||||
if i2 > 0 {
|
||||
ui[i2 - 1] = 1;
|
||||
}
|
||||
for i in 0..ntau {
|
||||
r2pat[i] = e2sub[i] ^ ui[i];
|
||||
}
|
||||
|
||||
osd_box.last_pattern = -1;
|
||||
osd_box.next_index = -1;
|
||||
|
||||
loop {
|
||||
let (in1, in2) = osd_box.fetchit(&r2pat, ntau);
|
||||
if in1 < 0 || in2 < 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
mi[..k].copy_from_slice(&misub[..k]);
|
||||
mi[in1 as usize] = 1;
|
||||
mi[in2 as usize] = 1;
|
||||
|
||||
let mut w = 0;
|
||||
let mut masked = false;
|
||||
for i in 0..k {
|
||||
w += mi[i] as usize;
|
||||
if apmaskr[i] != 0 && mi[i] != 0 {
|
||||
masked = true;
|
||||
}
|
||||
}
|
||||
|
||||
if w < nord + npre1 + npre2 || masked {
|
||||
continue;
|
||||
}
|
||||
|
||||
for i in 0..k {
|
||||
me[i] = m0[i] ^ mi[i];
|
||||
}
|
||||
mrbencode91(&me, &mut ce, &g2, n, k);
|
||||
|
||||
let mut dd = 0.0f32;
|
||||
let mut nh = 0i32;
|
||||
for i in 0..n {
|
||||
let diff = ce[i] ^ hdec[i];
|
||||
nh += diff as i32;
|
||||
if diff != 0 {
|
||||
dd += absrx[i];
|
||||
}
|
||||
}
|
||||
if dd < *dmin {
|
||||
*dmin = dd;
|
||||
cw[..n].copy_from_slice(&ce[..n]);
|
||||
*nhardmin = nh;
|
||||
}
|
||||
}
|
||||
}
|
||||
nextpat91(&mut misub, k, nord, &mut iflag);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reorder_result(cw, &indices, message91, nhardmin, dmin, llr);
|
||||
}
|
||||
|
||||
/// Reorder codeword back to original bit ordering and verify CRC.
|
||||
fn reorder_result(
|
||||
cw: &mut [u8; FTX_LDPC_N],
|
||||
indices: &[usize],
|
||||
message91: &mut [u8; FTX_LDPC_K],
|
||||
nhardmin: &mut i32,
|
||||
_dmin: &mut f32,
|
||||
_llr: &[f32; FTX_LDPC_N],
|
||||
) {
|
||||
let mut reordered = [0u8; FTX_LDPC_N];
|
||||
for i in 0..FTX_LDPC_N {
|
||||
reordered[indices[i]] = cw[i];
|
||||
}
|
||||
cw.copy_from_slice(&reordered);
|
||||
message91.copy_from_slice(&cw[..FTX_LDPC_K]);
|
||||
if !check_crc91(message91) {
|
||||
*nhardmin = -*nhardmin;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a reference to the cached generator matrix.
|
||||
/// The matrix is computed once on first call and reused thereafter.
|
||||
fn generator_matrix() -> &'static [[u8; FTX_LDPC_N]; FTX_LDPC_K] {
|
||||
static GEN: OnceLock<Box<[[u8; FTX_LDPC_N]; FTX_LDPC_K]>> = OnceLock::new();
|
||||
GEN.get_or_init(|| {
|
||||
let mut gen = Box::new([[0u8; FTX_LDPC_N]; FTX_LDPC_K]);
|
||||
for i in 0..FTX_LDPC_K {
|
||||
let mut msg = [0u8; FTX_LDPC_K];
|
||||
msg[i] = 1;
|
||||
if i < 77 {
|
||||
msg[77..FTX_LDPC_K].fill(0);
|
||||
}
|
||||
encode174_91_nocrc_bits(&msg, &mut gen[i]);
|
||||
}
|
||||
gen
|
||||
})
|
||||
}
|
||||
|
||||
/// Full iterative BP decoder with OSD refinement.
|
||||
///
|
||||
/// Runs belief-propagation for up to `maxiterations` iterations, saving
|
||||
/// accumulated LLR sums. If BP does not converge, falls back to OSD
|
||||
/// using the saved sums.
|
||||
///
|
||||
/// `llr`: input log-likelihood ratios (174 values).
|
||||
/// `keff`: effective K (must be 91).
|
||||
/// `maxosd`: maximum number of OSD passes (0-3).
|
||||
/// `norder`: OSD depth parameter.
|
||||
/// `apmask`: a priori mask.
|
||||
/// `message91`: output decoded 91-bit message.
|
||||
/// `cw`: output 174-bit codeword.
|
||||
/// `ntype`: output decode type (0=fail, 1=BP, 2=OSD).
|
||||
/// `nharderror`: output number of hard errors.
|
||||
/// `dmin`: output minimum distance.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn ft2_decode174_91_osd(
|
||||
llr: &mut [f32; FTX_LDPC_N],
|
||||
keff: usize,
|
||||
maxosd: usize,
|
||||
norder: usize,
|
||||
apmask: &mut [u8; FTX_LDPC_N],
|
||||
message91: &mut [u8; FTX_LDPC_K],
|
||||
cw: &mut [u8; FTX_LDPC_N],
|
||||
ntype: &mut i32,
|
||||
nharderror: &mut i32,
|
||||
dmin: &mut f32,
|
||||
) {
|
||||
*ntype = 0;
|
||||
*nharderror = -1;
|
||||
*dmin = 0.0;
|
||||
|
||||
if keff != FTX_LDPC_K {
|
||||
return;
|
||||
}
|
||||
|
||||
let maxiterations = 30;
|
||||
let maxosd = maxosd.min(3);
|
||||
|
||||
let nosd = if maxosd == 0 { 1 } else { maxosd };
|
||||
|
||||
let mut zsave = [[0.0f32; FTX_LDPC_N]; 3];
|
||||
if maxosd == 0 {
|
||||
zsave[0].copy_from_slice(llr);
|
||||
}
|
||||
|
||||
let mut tov = [[0.0f32; 3]; FTX_LDPC_N];
|
||||
let mut toc = [[0.0f32; 7]; FTX_LDPC_M];
|
||||
let mut zsum = [0.0f32; FTX_LDPC_N];
|
||||
let mut hdec = [0u8; FTX_LDPC_N];
|
||||
let mut best_cw = [0u8; FTX_LDPC_N];
|
||||
let mut ncnt = 0;
|
||||
let mut nclast = 0;
|
||||
|
||||
for iter in 0..=maxiterations {
|
||||
// Compute beliefs
|
||||
let mut zn = [0.0f32; FTX_LDPC_N];
|
||||
for i in 0..FTX_LDPC_N {
|
||||
zn[i] = llr[i];
|
||||
if apmask[i] != 1 {
|
||||
zn[i] += tov[i][0] + tov[i][1] + tov[i][2];
|
||||
}
|
||||
zsum[i] += zn[i];
|
||||
}
|
||||
if iter > 0 && iter <= maxosd {
|
||||
zsave[iter - 1].copy_from_slice(&zsum);
|
||||
}
|
||||
|
||||
// Hard decisions
|
||||
for i in 0..FTX_LDPC_N {
|
||||
best_cw[i] = if zn[i] > 0.0 { 1 } else { 0 };
|
||||
}
|
||||
let ncheck = ldpc_check(&best_cw);
|
||||
|
||||
if ncheck == 0 && check_crc91(&best_cw) {
|
||||
message91.copy_from_slice(&best_cw[..FTX_LDPC_K]);
|
||||
cw.copy_from_slice(&best_cw);
|
||||
for i in 0..FTX_LDPC_N {
|
||||
hdec[i] = if llr[i] >= 0.0 { 1 } else { 0 };
|
||||
}
|
||||
*nharderror = 0;
|
||||
*dmin = 0.0;
|
||||
for i in 0..FTX_LDPC_N {
|
||||
let diff = hdec[i] ^ best_cw[i];
|
||||
*nharderror += diff as i32;
|
||||
if diff != 0 {
|
||||
*dmin += llr[i].abs();
|
||||
}
|
||||
}
|
||||
*ntype = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Early termination
|
||||
if iter > 0 {
|
||||
let nd = ncheck - nclast;
|
||||
ncnt = if nd < 0 { 0 } else { ncnt + 1 };
|
||||
if ncnt >= 5 && iter >= 10 && ncheck > 15 {
|
||||
*nharderror = -1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
nclast = ncheck;
|
||||
|
||||
// Check-to-variable messages
|
||||
for m in 0..FTX_LDPC_M {
|
||||
let num_rows = FTX_LDPC_NUM_ROWS[m] as usize;
|
||||
for n_idx in 0..num_rows {
|
||||
let n = FTX_LDPC_NM[m][n_idx] as usize - 1;
|
||||
if n >= FTX_LDPC_N {
|
||||
continue;
|
||||
}
|
||||
toc[m][n_idx] = zn[n];
|
||||
for kk in 0..3 {
|
||||
if (FTX_LDPC_MN[n][kk] as usize).wrapping_sub(1) == m {
|
||||
toc[m][n_idx] -= tov[n][kk];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Variable-to-check messages
|
||||
for m in 0..FTX_LDPC_M {
|
||||
let num_rows = FTX_LDPC_NUM_ROWS[m] as usize;
|
||||
let mut tanhtoc = [0.0f32; 7];
|
||||
for i in 0..num_rows.min(7) {
|
||||
tanhtoc[i] = (-toc[m][i] / 2.0).tanh();
|
||||
}
|
||||
for &nm_val in FTX_LDPC_NM[m].iter().take(num_rows) {
|
||||
let n = nm_val as usize - 1;
|
||||
if n >= FTX_LDPC_N {
|
||||
continue;
|
||||
}
|
||||
let mut tmn = 1.0f32;
|
||||
for n_idx in 0..num_rows {
|
||||
if FTX_LDPC_NM[m][n_idx] as usize - 1 != n {
|
||||
tmn *= tanhtoc[n_idx];
|
||||
}
|
||||
}
|
||||
for kk in 0..3 {
|
||||
if (FTX_LDPC_MN[n][kk] as usize).wrapping_sub(1) == m {
|
||||
tov[n][kk] = 2.0 * platanh(-tmn);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// OSD fallback
|
||||
for i in 0..nosd {
|
||||
if i >= zsave.len() {
|
||||
break;
|
||||
}
|
||||
let mut osd_llr = [0.0f32; FTX_LDPC_N];
|
||||
osd_llr.copy_from_slice(&zsave[i]);
|
||||
let mut osd_harderror: i32 = -1;
|
||||
let mut osd_dmin: f32 = 0.0;
|
||||
osd174_91(
|
||||
&mut osd_llr,
|
||||
keff,
|
||||
apmask,
|
||||
norder,
|
||||
message91,
|
||||
cw,
|
||||
&mut osd_harderror,
|
||||
&mut osd_dmin,
|
||||
);
|
||||
if osd_harderror > 0 {
|
||||
*nharderror = osd_harderror;
|
||||
*dmin = 0.0;
|
||||
for j in 0..FTX_LDPC_N {
|
||||
hdec[j] = if llr[j] >= 0.0 { 1 } else { 0 };
|
||||
if (hdec[j] ^ cw[j]) != 0 {
|
||||
*dmin += llr[j].abs();
|
||||
}
|
||||
}
|
||||
*ntype = 2;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
*ntype = 0;
|
||||
*nharderror = -1;
|
||||
*dmin = 0.0;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::common::ldpc::fast_atanh;
|
||||
|
||||
#[test]
|
||||
fn ldpc_check_all_zeros() {
|
||||
let cw = [0u8; FTX_LDPC_N];
|
||||
assert_eq!(ldpc_check(&cw), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ldpc_check_single_bit_error() {
|
||||
let mut cw = [0u8; FTX_LDPC_N];
|
||||
cw[0] = 1;
|
||||
assert!(ldpc_check(&cw) > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fast_atanh_zero() {
|
||||
assert!(fast_atanh(0.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fast_atanh_approximation() {
|
||||
for &x in &[-0.5f32, -0.25, 0.25, 0.5] {
|
||||
let approx = fast_atanh(x);
|
||||
let exact = x.atanh();
|
||||
assert!(
|
||||
(approx - exact).abs() < 0.05,
|
||||
"fast_atanh({}) = {}, expected ~{}",
|
||||
x,
|
||||
approx,
|
||||
exact
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn platanh_small() {
|
||||
let result = platanh(0.5);
|
||||
assert!(result > 0.0);
|
||||
assert!(result.is_finite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn platanh_large() {
|
||||
let result = platanh(0.9999);
|
||||
assert!(result > 0.0);
|
||||
assert!(result.is_finite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn platanh_negative() {
|
||||
let pos = platanh(0.5);
|
||||
let neg = platanh(-0.5);
|
||||
assert!((pos + neg).abs() < 1e-6, "platanh should be odd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_pack_bits_basic() {
|
||||
let mut bits = [0u8; FTX_LDPC_K];
|
||||
bits[0] = 1;
|
||||
bits[7] = 1;
|
||||
let mut packed = [0u8; FTX_LDPC_K_BYTES];
|
||||
pack_bits(&bits, FTX_LDPC_K, &mut packed);
|
||||
assert_eq!(packed[0], 0x81);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_crc91_all_zeros() {
|
||||
// All-zero message likely fails CRC
|
||||
let bits = [0u8; FTX_LDPC_K];
|
||||
// CRC check result depends on specific polynomial behavior
|
||||
let _result = check_crc91(&bits);
|
||||
// Just verify it doesn't panic
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_parity8_basic() {
|
||||
assert_eq!(parity8(0x00), 0);
|
||||
assert_eq!(parity8(0x01), 1);
|
||||
assert_eq!(parity8(0x03), 0);
|
||||
assert_eq!(parity8(0xFF), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_hash_basic() {
|
||||
let e2 = [1u8, 0, 1, 0];
|
||||
assert_eq!(pattern_hash(&e2, 4), 0b1010);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_hash_all_zeros() {
|
||||
let e2 = [0u8; 16];
|
||||
assert_eq!(pattern_hash(&e2, 16), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nextpat91_basic() {
|
||||
let k = 5;
|
||||
let mut mi = vec![0u8; k];
|
||||
mi[4] = 1;
|
||||
let mut iflag = 4i32;
|
||||
nextpat91(&mut mi, k, 1, &mut iflag);
|
||||
// After one step, the pattern should shift
|
||||
assert!(iflag >= -1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generator_matrix_row_zero() {
|
||||
let gen = generator_matrix();
|
||||
// Row 0 should encode unit vector e_0
|
||||
assert_eq!(gen[0][0], 1);
|
||||
// Some parity bits should be non-zero
|
||||
let parity_nonzero = gen[0][FTX_LDPC_K..FTX_LDPC_N].iter().any(|&b| b != 0);
|
||||
assert!(parity_nonzero);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode174_91_nocrc_all_zeros() {
|
||||
let msg = [0u8; FTX_LDPC_K];
|
||||
let mut cw = [0u8; FTX_LDPC_N];
|
||||
encode174_91_nocrc_bits(&msg, &mut cw);
|
||||
for &b in &cw {
|
||||
assert_eq!(b, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osd_box_basic() {
|
||||
let mut b = OsdBox::new(4).unwrap();
|
||||
let pattern = [1u8, 0, 1, 0];
|
||||
b.boxit(&pattern, 4, 5, 3);
|
||||
let (i1, i2) = b.fetchit(&pattern, 4);
|
||||
assert_eq!(i1, 5);
|
||||
assert_eq!(i2, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osd_box_empty_fetch() {
|
||||
let mut b = OsdBox::new(4).unwrap();
|
||||
let pattern = [0u8; 4];
|
||||
let (i1, i2) = b.fetchit(&pattern, 4);
|
||||
assert_eq!(i1, -1);
|
||||
assert_eq!(i2, -1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/// FTx protocol variants.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FtxProtocol {
|
||||
Ft4,
|
||||
Ft8,
|
||||
#[cfg(feature = "ft2")]
|
||||
Ft2,
|
||||
}
|
||||
|
||||
impl FtxProtocol {
|
||||
/// Symbol period in seconds.
|
||||
pub fn symbol_period(self) -> f32 {
|
||||
match self {
|
||||
Self::Ft8 => FT8_SYMBOL_PERIOD,
|
||||
Self::Ft4 => FT4_SYMBOL_PERIOD,
|
||||
#[cfg(feature = "ft2")]
|
||||
Self::Ft2 => FT2_SYMBOL_PERIOD,
|
||||
}
|
||||
}
|
||||
|
||||
/// Slot time in seconds.
|
||||
pub fn slot_time(self) -> f32 {
|
||||
match self {
|
||||
Self::Ft8 => FT8_SLOT_TIME,
|
||||
Self::Ft4 => FT4_SLOT_TIME,
|
||||
#[cfg(feature = "ft2")]
|
||||
Self::Ft2 => FT2_SLOT_TIME,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this protocol uses FT4-style channel layout (FT4 and FT2).
|
||||
pub fn uses_ft4_layout(self) -> bool {
|
||||
#[cfg(feature = "ft2")]
|
||||
if matches!(self, Self::Ft2) {
|
||||
return true;
|
||||
}
|
||||
matches!(self, Self::Ft4)
|
||||
}
|
||||
|
||||
/// Number of data symbols.
|
||||
pub fn nd(self) -> usize {
|
||||
if self.uses_ft4_layout() {
|
||||
FT4_ND
|
||||
} else {
|
||||
FT8_ND
|
||||
}
|
||||
}
|
||||
|
||||
/// Total channel symbols.
|
||||
pub fn nn(self) -> usize {
|
||||
if self.uses_ft4_layout() {
|
||||
FT4_NN
|
||||
} else {
|
||||
FT8_NN
|
||||
}
|
||||
}
|
||||
|
||||
/// Length of each sync group.
|
||||
pub fn sync_length(self) -> usize {
|
||||
if self.uses_ft4_layout() {
|
||||
FT4_LENGTH_SYNC
|
||||
} else {
|
||||
FT8_LENGTH_SYNC
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of sync groups.
|
||||
pub fn num_sync(self) -> usize {
|
||||
if self.uses_ft4_layout() {
|
||||
FT4_NUM_SYNC
|
||||
} else {
|
||||
FT8_NUM_SYNC
|
||||
}
|
||||
}
|
||||
|
||||
/// Offset between sync groups.
|
||||
pub fn sync_offset(self) -> usize {
|
||||
if self.uses_ft4_layout() {
|
||||
FT4_SYNC_OFFSET
|
||||
} else {
|
||||
FT8_SYNC_OFFSET
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of FSK tones.
|
||||
pub fn num_tones(self) -> usize {
|
||||
if self.uses_ft4_layout() {
|
||||
4
|
||||
} else {
|
||||
8
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FT8 timing
|
||||
pub const FT8_SYMBOL_PERIOD: f32 = 0.160;
|
||||
pub const FT8_SLOT_TIME: f32 = 15.0;
|
||||
|
||||
// FT4 timing
|
||||
pub const FT4_SYMBOL_PERIOD: f32 = 0.048;
|
||||
pub const FT4_SLOT_TIME: f32 = 7.5;
|
||||
|
||||
// FT2 timing
|
||||
#[cfg(feature = "ft2")]
|
||||
pub const FT2_SYMBOL_PERIOD: f32 = 0.024;
|
||||
#[cfg(feature = "ft2")]
|
||||
pub const FT2_SLOT_TIME: f32 = 3.75;
|
||||
|
||||
// FT8 symbol counts
|
||||
pub const FT8_ND: usize = 58;
|
||||
pub const FT8_NN: usize = 79;
|
||||
pub const FT8_LENGTH_SYNC: usize = 7;
|
||||
pub const FT8_NUM_SYNC: usize = 3;
|
||||
pub const FT8_SYNC_OFFSET: usize = 36;
|
||||
|
||||
// FT4 symbol counts
|
||||
pub const FT4_ND: usize = 87;
|
||||
pub const FT4_NR: usize = 2;
|
||||
pub const FT4_NN: usize = 105;
|
||||
pub const FT4_LENGTH_SYNC: usize = 4;
|
||||
pub const FT4_NUM_SYNC: usize = 4;
|
||||
pub const FT4_SYNC_OFFSET: usize = 33;
|
||||
|
||||
// FT2 reuses FT4 layout
|
||||
#[cfg(feature = "ft2")]
|
||||
pub const FT2_ND: usize = FT4_ND;
|
||||
#[cfg(feature = "ft2")]
|
||||
pub const FT2_NR: usize = FT4_NR;
|
||||
#[cfg(feature = "ft2")]
|
||||
pub const FT2_NN: usize = FT4_NN;
|
||||
#[cfg(feature = "ft2")]
|
||||
pub const FT2_LENGTH_SYNC: usize = FT4_LENGTH_SYNC;
|
||||
#[cfg(feature = "ft2")]
|
||||
pub const FT2_NUM_SYNC: usize = FT4_NUM_SYNC;
|
||||
#[cfg(feature = "ft2")]
|
||||
pub const FT2_SYNC_OFFSET: usize = FT4_SYNC_OFFSET;
|
||||
|
||||
// LDPC parameters
|
||||
pub const FTX_LDPC_N: usize = 174;
|
||||
pub const FTX_LDPC_K: usize = 91;
|
||||
pub const FTX_LDPC_M: usize = 83;
|
||||
pub const FTX_LDPC_N_BYTES: usize = FTX_LDPC_N.div_ceil(8);
|
||||
pub const FTX_LDPC_K_BYTES: usize = FTX_LDPC_K.div_ceil(8);
|
||||
|
||||
// CRC parameters
|
||||
pub const FT8_CRC_POLYNOMIAL: u16 = 0x2757;
|
||||
pub const FT8_CRC_WIDTH: u32 = 14;
|
||||
|
||||
// Message parameters
|
||||
pub const FTX_PAYLOAD_LENGTH_BYTES: usize = 10;
|
||||
pub const FTX_MAX_MESSAGE_LENGTH: usize = 35;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn protocol_timing() {
|
||||
assert!((FtxProtocol::Ft8.symbol_period() - 0.160).abs() < 1e-6);
|
||||
assert!((FtxProtocol::Ft4.symbol_period() - 0.048).abs() < 1e-6);
|
||||
#[cfg(feature = "ft2")]
|
||||
assert!((FtxProtocol::Ft2.symbol_period() - 0.024).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ft4_layout() {
|
||||
assert!(FtxProtocol::Ft4.uses_ft4_layout());
|
||||
#[cfg(feature = "ft2")]
|
||||
assert!(FtxProtocol::Ft2.uses_ft4_layout());
|
||||
assert!(!FtxProtocol::Ft8.uses_ft4_layout());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbol_counts() {
|
||||
assert_eq!(FtxProtocol::Ft8.nn(), 79);
|
||||
assert_eq!(FtxProtocol::Ft4.nn(), 105);
|
||||
#[cfg(feature = "ft2")]
|
||||
assert_eq!(FtxProtocol::Ft2.nn(), 105);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! Character table lookup and string utility functions for FTx message
|
||||
//! encoding/decoding.
|
||||
//!
|
||||
//! This is a pure Rust port of `ft8_lib/ft8/text.c`.
|
||||
|
||||
/// Character table variants used for encoding and decoding FTx messages.
|
||||
///
|
||||
/// Each variant defines a different subset of allowed characters.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CharTable {
|
||||
/// `" 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ+-./?"` (42 entries)
|
||||
Full,
|
||||
/// `" 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ/"` (38 entries)
|
||||
AlphanumSpaceSlash,
|
||||
/// `" 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"` (37 entries)
|
||||
AlphanumSpace,
|
||||
/// `" ABCDEFGHIJKLMNOPQRSTUVWXYZ"` (27 entries)
|
||||
LettersSpace,
|
||||
/// `"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"` (36 entries)
|
||||
Alphanum,
|
||||
/// `"0123456789"` (10 entries)
|
||||
Numeric,
|
||||
}
|
||||
|
||||
/// Convert an integer index to an ASCII character according to the given
|
||||
/// character table.
|
||||
///
|
||||
/// Returns `'_'` if the index is out of range (should not happen in normal
|
||||
/// operation).
|
||||
pub fn charn(mut c: i32, table: CharTable) -> char {
|
||||
// Tables that include a leading space
|
||||
if table != CharTable::Alphanum && table != CharTable::Numeric {
|
||||
if c == 0 {
|
||||
return ' ';
|
||||
}
|
||||
c -= 1;
|
||||
}
|
||||
|
||||
// Digits (unless letters-space table which skips digits)
|
||||
if table != CharTable::LettersSpace {
|
||||
if c < 10 {
|
||||
return char::from(b'0' + c as u8);
|
||||
}
|
||||
c -= 10;
|
||||
}
|
||||
|
||||
// Letters (unless numeric table which has no letters)
|
||||
if table != CharTable::Numeric {
|
||||
if c < 26 {
|
||||
return char::from(b'A' + c as u8);
|
||||
}
|
||||
c -= 26;
|
||||
}
|
||||
|
||||
// Extra symbols
|
||||
match table {
|
||||
CharTable::Full => {
|
||||
const EXTRAS: [char; 5] = ['+', '-', '.', '/', '?'];
|
||||
if (c as usize) < EXTRAS.len() {
|
||||
return EXTRAS[c as usize];
|
||||
}
|
||||
}
|
||||
CharTable::AlphanumSpaceSlash => {
|
||||
if c == 0 {
|
||||
return '/';
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
'_' // unknown character — should never get here
|
||||
}
|
||||
|
||||
/// Look up the index of an ASCII character in the given character table.
|
||||
///
|
||||
/// Returns `None` if the character is not present in the table (the C version
|
||||
/// returns -1).
|
||||
pub fn nchar(c: char, table: CharTable) -> Option<i32> {
|
||||
let mut n: i32 = 0;
|
||||
|
||||
// Leading space
|
||||
if table != CharTable::Alphanum && table != CharTable::Numeric {
|
||||
if c == ' ' {
|
||||
return Some(n);
|
||||
}
|
||||
n += 1;
|
||||
}
|
||||
|
||||
// Digits
|
||||
if table != CharTable::LettersSpace {
|
||||
if c.is_ascii_digit() {
|
||||
return Some(n + (c as i32 - '0' as i32));
|
||||
}
|
||||
n += 10;
|
||||
}
|
||||
|
||||
// Letters
|
||||
if table != CharTable::Numeric {
|
||||
if c.is_ascii_uppercase() {
|
||||
return Some(n + (c as i32 - 'A' as i32));
|
||||
}
|
||||
n += 26;
|
||||
}
|
||||
|
||||
// Extra symbols
|
||||
match table {
|
||||
CharTable::Full => match c {
|
||||
'+' => return Some(n),
|
||||
'-' => return Some(n + 1),
|
||||
'.' => return Some(n + 2),
|
||||
'/' => return Some(n + 3),
|
||||
'?' => return Some(n + 4),
|
||||
_ => {}
|
||||
},
|
||||
CharTable::AlphanumSpaceSlash => {
|
||||
if c == '/' {
|
||||
return Some(n);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Convert a character to uppercase ASCII. Non-letter characters are returned
|
||||
/// unchanged.
|
||||
pub fn to_upper(c: char) -> char {
|
||||
if c.is_ascii_lowercase() {
|
||||
char::from(c as u8 - b'a' + b'A')
|
||||
} else {
|
||||
c
|
||||
}
|
||||
}
|
||||
|
||||
/// Format an FTx message string:
|
||||
/// - replaces lowercase letters with uppercase
|
||||
/// - collapses consecutive spaces into a single space
|
||||
pub fn fmtmsg(msg_in: &str) -> String {
|
||||
let mut out = String::with_capacity(msg_in.len());
|
||||
let mut last_out: Option<char> = None;
|
||||
|
||||
for c in msg_in.chars() {
|
||||
if c == ' ' && last_out == Some(' ') {
|
||||
continue;
|
||||
}
|
||||
let upper = to_upper(c);
|
||||
out.push(upper);
|
||||
last_out = Some(upper);
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// Parse a signed integer from a string slice.
|
||||
///
|
||||
/// Handles optional leading `+` or `-` sign, followed by decimal digits.
|
||||
/// Stops at the first non-digit character (or end of string).
|
||||
pub fn dd_to_int(s: &str) -> i32 {
|
||||
let bytes = s.as_bytes();
|
||||
if bytes.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let (negative, start) = match bytes[0] {
|
||||
b'-' => (true, 1),
|
||||
b'+' => (false, 1),
|
||||
_ => (false, 0),
|
||||
};
|
||||
|
||||
let mut result: i32 = 0;
|
||||
for &b in &bytes[start..] {
|
||||
if !b.is_ascii_digit() {
|
||||
break;
|
||||
}
|
||||
result = result * 10 + (b - b'0') as i32;
|
||||
}
|
||||
|
||||
if negative {
|
||||
-result
|
||||
} else {
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// Format an integer into a fixed-width decimal string.
|
||||
///
|
||||
/// * `value` – the integer value to format
|
||||
/// * `width` – number of digit positions (excluding sign)
|
||||
/// * `full_sign` – if `true`, a `+` is prepended for non-negative values
|
||||
pub fn int_to_dd(value: i32, width: usize, full_sign: bool) -> String {
|
||||
let mut out = String::with_capacity(width + 1);
|
||||
|
||||
let abs_value = if value < 0 {
|
||||
out.push('-');
|
||||
(-value) as u32
|
||||
} else {
|
||||
if full_sign {
|
||||
out.push('+');
|
||||
}
|
||||
value as u32
|
||||
};
|
||||
|
||||
if width == 0 {
|
||||
return out;
|
||||
}
|
||||
|
||||
let mut divisor: u32 = 1;
|
||||
for _ in 0..width - 1 {
|
||||
divisor *= 10;
|
||||
}
|
||||
|
||||
let mut remaining = abs_value;
|
||||
while divisor >= 1 {
|
||||
let digit = remaining / divisor;
|
||||
out.push(char::from(b'0' + digit as u8));
|
||||
remaining -= digit * divisor;
|
||||
divisor /= 10;
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// charn / nchar round-trip tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn full_table_round_trip() {
|
||||
let expected = " 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ+-./?";
|
||||
for (i, ch) in expected.chars().enumerate() {
|
||||
assert_eq!(charn(i as i32, CharTable::Full), ch, "charn({i})");
|
||||
assert_eq!(nchar(ch, CharTable::Full), Some(i as i32), "nchar('{ch}')");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alphanum_space_slash_round_trip() {
|
||||
let expected = " 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ/";
|
||||
for (i, ch) in expected.chars().enumerate() {
|
||||
assert_eq!(
|
||||
charn(i as i32, CharTable::AlphanumSpaceSlash),
|
||||
ch,
|
||||
"charn({i})"
|
||||
);
|
||||
assert_eq!(
|
||||
nchar(ch, CharTable::AlphanumSpaceSlash),
|
||||
Some(i as i32),
|
||||
"nchar('{ch}')"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alphanum_space_round_trip() {
|
||||
let expected = " 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
for (i, ch) in expected.chars().enumerate() {
|
||||
assert_eq!(charn(i as i32, CharTable::AlphanumSpace), ch, "charn({i})");
|
||||
assert_eq!(
|
||||
nchar(ch, CharTable::AlphanumSpace),
|
||||
Some(i as i32),
|
||||
"nchar('{ch}')"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn letters_space_round_trip() {
|
||||
let expected = " ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
for (i, ch) in expected.chars().enumerate() {
|
||||
assert_eq!(charn(i as i32, CharTable::LettersSpace), ch, "charn({i})");
|
||||
assert_eq!(
|
||||
nchar(ch, CharTable::LettersSpace),
|
||||
Some(i as i32),
|
||||
"nchar('{ch}')"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alphanum_round_trip() {
|
||||
let expected = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
for (i, ch) in expected.chars().enumerate() {
|
||||
assert_eq!(charn(i as i32, CharTable::Alphanum), ch, "charn({i})");
|
||||
assert_eq!(
|
||||
nchar(ch, CharTable::Alphanum),
|
||||
Some(i as i32),
|
||||
"nchar('{ch}')"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numeric_round_trip() {
|
||||
let expected = "0123456789";
|
||||
for (i, ch) in expected.chars().enumerate() {
|
||||
assert_eq!(charn(i as i32, CharTable::Numeric), ch, "charn({i})");
|
||||
assert_eq!(
|
||||
nchar(ch, CharTable::Numeric),
|
||||
Some(i as i32),
|
||||
"nchar('{ch}')"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nchar_returns_none_for_unknown() {
|
||||
assert_eq!(nchar('!', CharTable::Full), None);
|
||||
assert_eq!(nchar('a', CharTable::Full), None); // lowercase not in table
|
||||
assert_eq!(nchar(' ', CharTable::Alphanum), None);
|
||||
assert_eq!(nchar('A', CharTable::Numeric), None);
|
||||
assert_eq!(nchar('0', CharTable::LettersSpace), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn charn_returns_underscore_for_out_of_range() {
|
||||
assert_eq!(charn(42, CharTable::Full), '_');
|
||||
assert_eq!(charn(38, CharTable::AlphanumSpaceSlash), '_');
|
||||
assert_eq!(charn(10, CharTable::Numeric), '_');
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// to_upper
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn to_upper_converts_lowercase() {
|
||||
assert_eq!(to_upper('a'), 'A');
|
||||
assert_eq!(to_upper('z'), 'Z');
|
||||
assert_eq!(to_upper('m'), 'M');
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_upper_preserves_non_lower() {
|
||||
assert_eq!(to_upper('A'), 'A');
|
||||
assert_eq!(to_upper('5'), '5');
|
||||
assert_eq!(to_upper(' '), ' ');
|
||||
assert_eq!(to_upper('/'), '/');
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// fmtmsg
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn fmtmsg_uppercases_and_collapses_spaces() {
|
||||
assert_eq!(fmtmsg("cq dx de ab1cd"), "CQ DX DE AB1CD");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fmtmsg_preserves_single_spaces() {
|
||||
assert_eq!(fmtmsg("CQ DX"), "CQ DX");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fmtmsg_empty() {
|
||||
assert_eq!(fmtmsg(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fmtmsg_all_spaces() {
|
||||
assert_eq!(fmtmsg(" "), " ");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// dd_to_int
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn dd_to_int_positive() {
|
||||
assert_eq!(dd_to_int("42"), 42);
|
||||
assert_eq!(dd_to_int("+42"), 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dd_to_int_negative() {
|
||||
assert_eq!(dd_to_int("-7"), -7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dd_to_int_stops_at_non_digit() {
|
||||
assert_eq!(dd_to_int("12abc"), 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dd_to_int_empty() {
|
||||
assert_eq!(dd_to_int(""), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dd_to_int_sign_only() {
|
||||
assert_eq!(dd_to_int("-"), 0);
|
||||
assert_eq!(dd_to_int("+"), 0);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// int_to_dd
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn int_to_dd_positive_no_sign() {
|
||||
assert_eq!(int_to_dd(7, 2, false), "07");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn int_to_dd_positive_with_sign() {
|
||||
assert_eq!(int_to_dd(7, 2, true), "+07");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn int_to_dd_negative() {
|
||||
assert_eq!(int_to_dd(-15, 2, false), "-15");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn int_to_dd_zero() {
|
||||
assert_eq!(int_to_dd(0, 2, false), "00");
|
||||
assert_eq!(int_to_dd(0, 2, true), "+00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn int_to_dd_width_3() {
|
||||
assert_eq!(int_to_dd(123, 3, false), "123");
|
||||
assert_eq!(int_to_dd(5, 3, true), "+005");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! Top-level FTx decoder matching the `trx-ft8` public API.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::common::callsign_hash::CallsignHashTable;
|
||||
use crate::common::decode::{
|
||||
ftx_decode_candidate, ftx_find_candidates, ftx_post_decode_snr, FtxMessage,
|
||||
};
|
||||
use crate::common::message;
|
||||
use crate::common::monitor::{Monitor, MonitorConfig};
|
||||
use crate::common::protocol::*;
|
||||
|
||||
const DEFAULT_F_MIN_HZ: f32 = 200.0;
|
||||
const DEFAULT_F_MAX_HZ: f32 = 3000.0;
|
||||
const DEFAULT_TIME_OSR: i32 = 2;
|
||||
const DEFAULT_FREQ_OSR: i32 = 2;
|
||||
|
||||
#[cfg(feature = "ft2")]
|
||||
const FT2_F_MIN_HZ: f32 = 200.0;
|
||||
#[cfg(feature = "ft2")]
|
||||
const FT2_F_MAX_HZ: f32 = 5000.0;
|
||||
#[cfg(feature = "ft2")]
|
||||
const FT2_TIME_OSR: i32 = 8;
|
||||
#[cfg(feature = "ft2")]
|
||||
const FT2_FREQ_OSR: i32 = 4;
|
||||
|
||||
const MAX_LDPC_ITERATIONS: usize = 20;
|
||||
const MIN_CANDIDATE_SCORE: i32 = 10;
|
||||
const MAX_CANDIDATES: usize = 120;
|
||||
|
||||
/// Decoded result from the FT8/FT4/FT2 decoder.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Ft8DecodeResult {
|
||||
pub text: String,
|
||||
pub snr_db: f32,
|
||||
pub dt_s: f32,
|
||||
pub freq_hz: f32,
|
||||
}
|
||||
|
||||
/// FTx decoder instance supporting FT8, FT4, and (optionally) FT2 protocols.
|
||||
pub struct Ft8Decoder {
|
||||
protocol: FtxProtocol,
|
||||
sample_rate: u32,
|
||||
block_size: usize,
|
||||
window_samples: usize,
|
||||
monitor: Monitor,
|
||||
callsign_hash: CallsignHashTable,
|
||||
// FT2-specific pipeline
|
||||
#[cfg(feature = "ft2")]
|
||||
ft2_pipeline: Option<crate::ft2::Ft2Pipeline>,
|
||||
}
|
||||
|
||||
impl Ft8Decoder {
|
||||
/// Create a new FT8 decoder.
|
||||
pub fn new(sample_rate: u32) -> Result<Self, String> {
|
||||
Self::new_with_protocol(sample_rate, FtxProtocol::Ft8)
|
||||
}
|
||||
|
||||
/// Create a new FT4 decoder.
|
||||
pub fn new_ft4(sample_rate: u32) -> Result<Self, String> {
|
||||
Self::new_with_protocol(sample_rate, FtxProtocol::Ft4)
|
||||
}
|
||||
|
||||
/// Create a new FT2 decoder.
|
||||
#[cfg(feature = "ft2")]
|
||||
pub fn new_ft2(sample_rate: u32) -> Result<Self, String> {
|
||||
Self::new_with_protocol(sample_rate, FtxProtocol::Ft2)
|
||||
}
|
||||
|
||||
fn new_with_protocol(sample_rate: u32, protocol: FtxProtocol) -> Result<Self, String> {
|
||||
let (f_min, f_max, time_osr, freq_osr) = match protocol {
|
||||
#[cfg(feature = "ft2")]
|
||||
FtxProtocol::Ft2 => (FT2_F_MIN_HZ, FT2_F_MAX_HZ, FT2_TIME_OSR, FT2_FREQ_OSR),
|
||||
_ => (
|
||||
DEFAULT_F_MIN_HZ,
|
||||
DEFAULT_F_MAX_HZ,
|
||||
DEFAULT_TIME_OSR,
|
||||
DEFAULT_FREQ_OSR,
|
||||
),
|
||||
};
|
||||
|
||||
let cfg = MonitorConfig {
|
||||
f_min,
|
||||
f_max,
|
||||
sample_rate: sample_rate as i32,
|
||||
time_osr,
|
||||
freq_osr,
|
||||
protocol,
|
||||
};
|
||||
|
||||
let monitor = Monitor::new(&cfg);
|
||||
let block_size = monitor.block_size;
|
||||
|
||||
if block_size == 0 {
|
||||
return Err(format!("invalid {:?} block size", protocol));
|
||||
}
|
||||
|
||||
let window_samples = {
|
||||
#[cfg(feature = "ft2")]
|
||||
if protocol == FtxProtocol::Ft2 {
|
||||
crate::ft2::FT2_NMAX
|
||||
} else {
|
||||
let slot_time = protocol.slot_time();
|
||||
(sample_rate as f32 * slot_time) as usize
|
||||
}
|
||||
#[cfg(not(feature = "ft2"))]
|
||||
{
|
||||
let slot_time = protocol.slot_time();
|
||||
(sample_rate as f32 * slot_time) as usize
|
||||
}
|
||||
};
|
||||
|
||||
if window_samples == 0 {
|
||||
return Err(format!("invalid {:?} analysis window", protocol));
|
||||
}
|
||||
|
||||
#[cfg(feature = "ft2")]
|
||||
let ft2_pipeline = if protocol == FtxProtocol::Ft2 {
|
||||
Some(crate::ft2::Ft2Pipeline::new(sample_rate as i32))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
protocol,
|
||||
sample_rate,
|
||||
block_size,
|
||||
window_samples,
|
||||
monitor,
|
||||
callsign_hash: CallsignHashTable::new(),
|
||||
#[cfg(feature = "ft2")]
|
||||
ft2_pipeline,
|
||||
})
|
||||
}
|
||||
|
||||
/// Block size in samples for `process_block`.
|
||||
pub fn block_size(&self) -> usize {
|
||||
self.block_size
|
||||
}
|
||||
|
||||
/// The sample rate this decoder was configured with.
|
||||
pub fn sample_rate(&self) -> u32 {
|
||||
self.sample_rate
|
||||
}
|
||||
|
||||
/// Total analysis window in samples.
|
||||
pub fn window_samples(&self) -> usize {
|
||||
self.window_samples
|
||||
}
|
||||
|
||||
/// Reset the decoder state for a new decode cycle.
|
||||
pub fn reset(&mut self) {
|
||||
self.monitor.reset();
|
||||
self.callsign_hash.cleanup(10);
|
||||
#[cfg(feature = "ft2")]
|
||||
if let Some(ref mut pipe) = self.ft2_pipeline {
|
||||
pipe.reset();
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed one block of audio samples to the decoder.
|
||||
pub fn process_block(&mut self, block: &[f32]) {
|
||||
if block.len() < self.block_size {
|
||||
return;
|
||||
}
|
||||
|
||||
#[cfg(feature = "ft2")]
|
||||
if self.protocol == FtxProtocol::Ft2 {
|
||||
// FT2: accumulate raw audio and also feed the monitor
|
||||
if let Some(ref mut pipe) = self.ft2_pipeline {
|
||||
pipe.accumulate(block);
|
||||
}
|
||||
}
|
||||
|
||||
self.monitor.process(block);
|
||||
}
|
||||
|
||||
/// Check if enough data has been collected and run the decode.
|
||||
/// Returns decoded messages, or empty if not ready yet.
|
||||
pub fn decode_if_ready(&mut self, max_results: usize) -> Vec<Ft8DecodeResult> {
|
||||
#[cfg(feature = "ft2")]
|
||||
if self.protocol == FtxProtocol::Ft2 {
|
||||
return self.decode_ft2(max_results);
|
||||
}
|
||||
|
||||
// FT8/FT4: waterfall-based decode
|
||||
if self.monitor.wf.num_blocks < self.monitor.wf.max_blocks {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
self.decode_waterfall(max_results)
|
||||
}
|
||||
|
||||
/// Waterfall-based decode for FT8/FT4.
|
||||
fn decode_waterfall(&mut self, max_results: usize) -> Vec<Ft8DecodeResult> {
|
||||
let candidates = ftx_find_candidates(&self.monitor.wf, MAX_CANDIDATES, MIN_CANDIDATE_SCORE);
|
||||
|
||||
let mut results = Vec::new();
|
||||
let mut seen = HashSet::with_capacity(max_results);
|
||||
|
||||
for cand in &candidates {
|
||||
if results.len() >= max_results {
|
||||
break;
|
||||
}
|
||||
|
||||
let msg = match ftx_decode_candidate(&self.monitor.wf, cand, MAX_LDPC_ITERATIONS) {
|
||||
Some(m) => m,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// Dedup by hash (O(1) lookup via HashSet)
|
||||
if !seen.insert(msg.hash) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unpack message text
|
||||
let text = match self.unpack_message(&msg) {
|
||||
Some(t) => t,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// Compute SNR
|
||||
let snr_db = ftx_post_decode_snr(&self.monitor.wf, cand, &msg);
|
||||
|
||||
// Compute time offset
|
||||
let symbol_period = self.protocol.symbol_period();
|
||||
let dt_s = (cand.time_offset as f32
|
||||
+ cand.time_sub as f32 / self.monitor.wf.time_osr as f32)
|
||||
* symbol_period
|
||||
- 0.5;
|
||||
|
||||
// Compute frequency
|
||||
let freq_hz = (self.monitor.min_bin as f32
|
||||
+ cand.freq_offset as f32
|
||||
+ cand.freq_sub as f32 / self.monitor.wf.freq_osr as f32)
|
||||
/ symbol_period;
|
||||
|
||||
results.push(Ft8DecodeResult {
|
||||
text,
|
||||
snr_db,
|
||||
dt_s,
|
||||
freq_hz,
|
||||
});
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
/// FT2-specific decode pipeline.
|
||||
#[cfg(feature = "ft2")]
|
||||
fn decode_ft2(&mut self, max_results: usize) -> Vec<Ft8DecodeResult> {
|
||||
let ft2_results = {
|
||||
let pipe = match self.ft2_pipeline.as_mut() {
|
||||
Some(p) => p,
|
||||
None => return Vec::new(),
|
||||
};
|
||||
|
||||
if !pipe.is_ready() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
pipe.decode(max_results)
|
||||
};
|
||||
let mut results = Vec::new();
|
||||
|
||||
for r in ft2_results {
|
||||
let text = match self.unpack_message(&r.message) {
|
||||
Some(t) => t,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
results.push(Ft8DecodeResult {
|
||||
text,
|
||||
snr_db: r.snr_db,
|
||||
dt_s: r.dt_s,
|
||||
freq_hz: r.freq_hz,
|
||||
});
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
/// Unpack a decoded FtxMessage into a human-readable string.
|
||||
fn unpack_message(&mut self, msg: &FtxMessage) -> Option<String> {
|
||||
let m = message::FtxMessage {
|
||||
payload: msg.payload,
|
||||
hash: msg.hash as u32,
|
||||
};
|
||||
let (text, _offsets, _rc) = message::ftx_message_decode(&m, &mut self.callsign_hash);
|
||||
if text.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(text)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ft8_decoder_creates() {
|
||||
let dec = Ft8Decoder::new(12_000).expect("ft8 decoder");
|
||||
assert_eq!(dec.block_size(), 1920); // 12000 * 0.160
|
||||
assert_eq!(dec.sample_rate(), 12_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ft4_decoder_creates() {
|
||||
let dec = Ft8Decoder::new_ft4(12_000).expect("ft4 decoder");
|
||||
assert_eq!(dec.block_size(), 576); // 12000 * 0.048
|
||||
}
|
||||
|
||||
#[cfg(feature = "ft2")]
|
||||
#[test]
|
||||
fn ft2_uses_distinct_block_size() {
|
||||
let ft4 = Ft8Decoder::new_ft4(12_000).expect("ft4 decoder");
|
||||
let ft2 = Ft8Decoder::new_ft2(12_000).expect("ft2 decoder");
|
||||
|
||||
assert!(ft2.block_size() < ft4.block_size());
|
||||
assert_eq!(ft4.block_size(), 576);
|
||||
assert_eq!(ft2.block_size(), 288);
|
||||
assert_eq!(ft2.window_samples(), 45_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_reset() {
|
||||
let mut dec = Ft8Decoder::new(12_000).expect("ft8 decoder");
|
||||
dec.reset();
|
||||
// Should not panic
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_empty_returns_nothing() {
|
||||
let mut dec = Ft8Decoder::new(12_000).expect("ft8 decoder");
|
||||
let results = dec.decode_if_ready(10);
|
||||
assert!(results.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! Per-symbol FFT and multi-scale bit metrics extraction.
|
||||
//!
|
||||
//! Takes the downsampled complex signal, computes per-symbol FFTs to extract
|
||||
//! complex tone amplitudes, and generates bit metrics at three scales:
|
||||
//! 1-symbol, 2-symbol, and 4-symbol coherent integration.
|
||||
|
||||
use num_complex::Complex32;
|
||||
use rustfft::FftPlanner;
|
||||
|
||||
use crate::common::constants::{FT4_COSTAS_PATTERN, FT4_GRAY_MAP};
|
||||
|
||||
use super::{FT2_FRAME_SYMBOLS, FT2_NSS};
|
||||
|
||||
const N_METRICS: usize = 2 * FT2_FRAME_SYMBOLS;
|
||||
|
||||
/// Reusable FFT plans and scratch buffers for bit-metric extraction.
|
||||
pub struct BitMetricsWorkspace {
|
||||
fft: std::sync::Arc<dyn rustfft::Fft<f32>>,
|
||||
scratch: Vec<Complex32>,
|
||||
symbols: [[Complex32; 4]; FT2_FRAME_SYMBOLS],
|
||||
s4: [[f32; 4]; FT2_FRAME_SYMBOLS],
|
||||
metric1: [f32; N_METRICS],
|
||||
metric2: [f32; N_METRICS],
|
||||
metric4: [f32; N_METRICS],
|
||||
bitmetrics: [[f32; 3]; N_METRICS],
|
||||
csymb: [Complex32; FT2_NSS],
|
||||
}
|
||||
|
||||
impl BitMetricsWorkspace {
|
||||
pub fn new() -> Self {
|
||||
let mut planner = FftPlanner::<f32>::new();
|
||||
let fft = planner.plan_fft_forward(FT2_NSS);
|
||||
let scratch = vec![Complex32::new(0.0, 0.0); fft.get_inplace_scratch_len()];
|
||||
|
||||
Self {
|
||||
fft,
|
||||
scratch,
|
||||
symbols: [[Complex32::new(0.0, 0.0); 4]; FT2_FRAME_SYMBOLS],
|
||||
s4: [[0.0; 4]; FT2_FRAME_SYMBOLS],
|
||||
metric1: [0.0; N_METRICS],
|
||||
metric2: [0.0; N_METRICS],
|
||||
metric4: [0.0; N_METRICS],
|
||||
bitmetrics: [[0.0; 3]; N_METRICS],
|
||||
csymb: [Complex32::new(0.0, 0.0); FT2_NSS],
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract bit metrics into a reusable internal buffer.
|
||||
pub fn extract<'a>(&'a mut self, signal: &[Complex32]) -> Option<&'a [[f32; 3]]> {
|
||||
self.metric1.fill(0.0);
|
||||
self.metric2.fill(0.0);
|
||||
self.metric4.fill(0.0);
|
||||
|
||||
for sym in 0..FT2_FRAME_SYMBOLS {
|
||||
let offset = sym * FT2_NSS;
|
||||
if offset + FT2_NSS <= signal.len() {
|
||||
self.csymb
|
||||
.copy_from_slice(&signal[offset..(offset + FT2_NSS)]);
|
||||
} else {
|
||||
self.csymb.fill(Complex32::new(0.0, 0.0));
|
||||
let remaining = signal.len().saturating_sub(offset);
|
||||
self.csymb[..remaining].copy_from_slice(&signal[offset..(offset + remaining)]);
|
||||
}
|
||||
|
||||
self.fft
|
||||
.process_with_scratch(&mut self.csymb, &mut self.scratch);
|
||||
|
||||
for tone in 0..4 {
|
||||
let symbol = self.csymb[tone];
|
||||
self.symbols[sym][tone] = symbol;
|
||||
self.s4[sym][tone] = symbol.norm();
|
||||
}
|
||||
}
|
||||
|
||||
// Sync quality check: verify Costas patterns are detectable
|
||||
let mut sync_ok = 0;
|
||||
for (group, costas_group) in FT4_COSTAS_PATTERN.iter().enumerate() {
|
||||
let base = group * 33;
|
||||
for (i, &costas_tone) in costas_group.iter().enumerate() {
|
||||
if base + i >= FT2_FRAME_SYMBOLS {
|
||||
continue;
|
||||
}
|
||||
let mut best = 0;
|
||||
for tone in 1..4 {
|
||||
if self.s4[base + i][tone] > self.s4[base + i][best] {
|
||||
best = tone;
|
||||
}
|
||||
}
|
||||
if best == costas_tone as usize {
|
||||
sync_ok += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if sync_ok < 4 {
|
||||
return None;
|
||||
}
|
||||
|
||||
for nseq in 0..3 {
|
||||
let (nsym, metric): (usize, &mut [f32; N_METRICS]) = match nseq {
|
||||
0 => (1, &mut self.metric1),
|
||||
1 => (2, &mut self.metric2),
|
||||
_ => (4, &mut self.metric4),
|
||||
};
|
||||
let nt = 1usize << (2 * nsym);
|
||||
let ibmax = match nsym {
|
||||
1 => 1,
|
||||
2 => 3,
|
||||
4 => 7,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
let mut ks = 0;
|
||||
while ks + nsym <= FT2_FRAME_SYMBOLS {
|
||||
let mut max_one = [f32::NEG_INFINITY; 8];
|
||||
let mut max_zero = [f32::NEG_INFINITY; 8];
|
||||
|
||||
for i in 0..nt {
|
||||
let sum = match nsym {
|
||||
1 => self.symbols[ks][FT4_GRAY_MAP[i & 0x03] as usize],
|
||||
2 => {
|
||||
self.symbols[ks][FT4_GRAY_MAP[(i >> 2) & 0x03] as usize]
|
||||
+ self.symbols[ks + 1][FT4_GRAY_MAP[i & 0x03] as usize]
|
||||
}
|
||||
4 => {
|
||||
self.symbols[ks][FT4_GRAY_MAP[(i >> 6) & 0x03] as usize]
|
||||
+ self.symbols[ks + 1][FT4_GRAY_MAP[(i >> 4) & 0x03] as usize]
|
||||
+ self.symbols[ks + 2][FT4_GRAY_MAP[(i >> 2) & 0x03] as usize]
|
||||
+ self.symbols[ks + 3][FT4_GRAY_MAP[i & 0x03] as usize]
|
||||
}
|
||||
_ => Complex32::new(0.0, 0.0),
|
||||
};
|
||||
let coherent = sum.norm();
|
||||
|
||||
for ib in 0..=ibmax {
|
||||
if ((i >> (ibmax - ib)) & 1) != 0 {
|
||||
max_one[ib] = max_one[ib].max(coherent);
|
||||
} else {
|
||||
max_zero[ib] = max_zero[ib].max(coherent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let ipt = 2 * ks;
|
||||
for ib in 0..=ibmax {
|
||||
let metric_idx = ipt + ib;
|
||||
if metric_idx < N_METRICS {
|
||||
metric[metric_idx] = max_one[ib] - max_zero[ib];
|
||||
}
|
||||
}
|
||||
|
||||
ks += nsym;
|
||||
}
|
||||
}
|
||||
|
||||
// Patch boundary metrics where multi-symbol integration overruns
|
||||
self.metric2[204] = self.metric1[204];
|
||||
self.metric2[205] = self.metric1[205];
|
||||
self.metric4[200] = self.metric2[200];
|
||||
self.metric4[201] = self.metric2[201];
|
||||
self.metric4[202] = self.metric2[202];
|
||||
self.metric4[203] = self.metric2[203];
|
||||
self.metric4[204] = self.metric1[204];
|
||||
self.metric4[205] = self.metric1[205];
|
||||
|
||||
normalize_metric(&mut self.metric1);
|
||||
normalize_metric(&mut self.metric2);
|
||||
normalize_metric(&mut self.metric4);
|
||||
|
||||
for i in 0..N_METRICS {
|
||||
self.bitmetrics[i][0] = self.metric1[i];
|
||||
self.bitmetrics[i][1] = self.metric2[i];
|
||||
self.bitmetrics[i][2] = self.metric4[i];
|
||||
}
|
||||
|
||||
Some(&self.bitmetrics)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for BitMetricsWorkspace {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract bit metrics from the downsampled signal region.
|
||||
///
|
||||
/// Returns a 2D array of shape `[2 * FT2_FRAME_SYMBOLS][3]` where:
|
||||
/// - Index 0: 1-symbol scale metric
|
||||
/// - Index 1: 2-symbol scale metric
|
||||
/// - Index 2: 4-symbol scale metric
|
||||
///
|
||||
/// Returns `None` if the sync quality is too poor (fewer than 4 of 16
|
||||
/// Costas sync tones decoded correctly).
|
||||
pub fn extract_bitmetrics_raw(signal: &[Complex32]) -> Option<Vec<[f32; 3]>> {
|
||||
let mut workspace = BitMetricsWorkspace::new();
|
||||
workspace
|
||||
.extract(signal)
|
||||
.map(|bitmetrics| bitmetrics.to_vec())
|
||||
}
|
||||
|
||||
/// Normalize a metric array by dividing by its standard deviation.
|
||||
fn normalize_metric(metric: &mut [f32]) {
|
||||
let count = metric.len();
|
||||
if count == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut sum = 0.0f32;
|
||||
let mut sum2 = 0.0f32;
|
||||
for &v in metric.iter() {
|
||||
sum += v;
|
||||
sum2 += v * v;
|
||||
}
|
||||
|
||||
let mean = sum / count as f32;
|
||||
let variance = (sum2 / count as f32) - (mean * mean);
|
||||
let sigma = if variance > 0.0 {
|
||||
variance.sqrt()
|
||||
} else {
|
||||
(sum2 / count as f32).max(0.0).sqrt()
|
||||
};
|
||||
|
||||
if sigma <= 1e-6 {
|
||||
return;
|
||||
}
|
||||
|
||||
for v in metric.iter_mut() {
|
||||
*v /= sigma;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn normalize_metric_zeros() {
|
||||
let mut m = vec![0.0f32; 100];
|
||||
normalize_metric(&mut m);
|
||||
for &v in &m {
|
||||
assert_eq!(v, 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_metric_uniform() {
|
||||
let mut m = vec![1.0f32; 100];
|
||||
normalize_metric(&mut m);
|
||||
// All values are the same so variance is zero, sigma will be computed
|
||||
// from sum2/n which is 1.0, so sigma=1.0 and values remain 1.0
|
||||
for &v in &m {
|
||||
assert!((v - 1.0).abs() < 1e-4);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_metric_nonzero() {
|
||||
let mut m: Vec<f32> = (0..100).map(|i| (i as f32 - 50.0) * 0.1).collect();
|
||||
normalize_metric(&mut m);
|
||||
// After normalization, standard deviation should be ~1.0
|
||||
let mean: f32 = m.iter().sum::<f32>() / m.len() as f32;
|
||||
let variance: f32 =
|
||||
m.iter().map(|&v| (v - mean) * (v - mean)).sum::<f32>() / m.len() as f32;
|
||||
assert!(
|
||||
(variance - 1.0).abs() < 0.1,
|
||||
"Normalized variance should be ~1.0, got {}",
|
||||
variance
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_bitmetrics_silent_signal() {
|
||||
let signal = vec![Complex32::new(0.0, 0.0); FT2_FRAME_SYMBOLS * FT2_NSS];
|
||||
// Silent signal: all tones have zero magnitude, so the "best tone"
|
||||
// defaults to tone 0 for every symbol. When tone 0 happens to match
|
||||
// the Costas pattern (which it does for some groups), sync_ok may
|
||||
// reach >= 4. So a silent signal can still pass the sync quality
|
||||
// gate — the important thing is it does not panic.
|
||||
let _result = extract_bitmetrics_raw(&signal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_symbols_constant() {
|
||||
// FT2_NN=105, FT2_NR=2 => FT2_FRAME_SYMBOLS=103
|
||||
assert_eq!(FT2_FRAME_SYMBOLS, 103);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nss_constant() {
|
||||
// FT2_NSTEP=288, FT2_NDOWN=9 => FT2_NSS=32
|
||||
assert_eq!(FT2_NSS, 32);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! FT2-specific waterfall sync scoring and likelihood extraction.
|
||||
|
||||
use num_complex::Complex32;
|
||||
|
||||
use crate::common::constants::*;
|
||||
use crate::common::decode::{get_cand_offset, wf_elem_to_complex, wf_mag_safe, Candidate};
|
||||
use crate::common::monitor::Waterfall;
|
||||
use crate::common::protocol::*;
|
||||
|
||||
/// Compute FT2 sync score for a candidate (coherent multi-tone).
|
||||
pub(crate) fn ft2_sync_score(wf: &Waterfall, cand: &Candidate) -> i32 {
|
||||
let base = get_cand_offset(wf, cand);
|
||||
let mut score_f: f32 = 0.0;
|
||||
let mut groups = 0;
|
||||
|
||||
for (m, costas_group) in FT4_COSTAS_PATTERN.iter().enumerate().take(FT2_NUM_SYNC) {
|
||||
let mut sum = Complex32::new(0.0, 0.0);
|
||||
let mut complete = true;
|
||||
for (k, &costas_tone) in costas_group.iter().enumerate().take(FT2_LENGTH_SYNC) {
|
||||
let block = 1 + FT2_SYNC_OFFSET * m + k;
|
||||
let block_abs = cand.time_offset as i32 + block as i32;
|
||||
if block_abs < 0 || block_abs >= wf.num_blocks as i32 {
|
||||
complete = false;
|
||||
break;
|
||||
}
|
||||
let sym_offset = base + block * wf.block_stride;
|
||||
let tone = costas_tone as usize;
|
||||
let elem = *wf_mag_safe(wf, sym_offset + tone);
|
||||
sum += wf_elem_to_complex(elem);
|
||||
}
|
||||
if !complete {
|
||||
continue;
|
||||
}
|
||||
score_f += sum.norm();
|
||||
groups += 1;
|
||||
}
|
||||
|
||||
if groups == 0 {
|
||||
return 0;
|
||||
}
|
||||
(score_f / groups as f32 * 8.0).round() as i32
|
||||
}
|
||||
|
||||
/// Extract log-likelihood ratios for FT2 symbols (multi-scale coherent).
|
||||
pub(crate) fn ft2_extract_likelihood(
|
||||
wf: &Waterfall,
|
||||
cand: &Candidate,
|
||||
log174: &mut [f32; FTX_LDPC_N],
|
||||
) {
|
||||
let base = get_cand_offset(wf, cand);
|
||||
let frame_syms = FT2_NN - FT2_NR;
|
||||
|
||||
// Collect complex symbols
|
||||
let mut symbols = [[Complex32::new(0.0, 0.0); 103]; 4]; // FT2_NN - FT2_NR = 103
|
||||
for frame_sym in 0..frame_syms {
|
||||
let sym_idx = frame_sym + 1; // skip ramp-up
|
||||
let block = cand.time_offset as i32 + sym_idx as i32;
|
||||
if block < 0 || block >= wf.num_blocks as i32 {
|
||||
continue;
|
||||
}
|
||||
let sym_offset = base + sym_idx * wf.block_stride;
|
||||
for (tone, symbol_row) in symbols.iter_mut().enumerate().take(4) {
|
||||
let elem = *wf_mag_safe(wf, sym_offset + tone);
|
||||
symbol_row[frame_sym] = wf_elem_to_complex(elem);
|
||||
}
|
||||
}
|
||||
|
||||
// Multi-scale metrics
|
||||
let mut metric1 = vec![0.0f32; 2 * frame_syms];
|
||||
let mut metric2 = vec![0.0f32; 2 * frame_syms];
|
||||
let mut metric4 = vec![0.0f32; 2 * frame_syms];
|
||||
|
||||
for start in 0..frame_syms {
|
||||
ft2_extract_logl_seq(&symbols, start, 1, &mut metric1[2 * start..]);
|
||||
}
|
||||
let mut start = 0;
|
||||
while start + 1 < frame_syms {
|
||||
ft2_extract_logl_seq(&symbols, start, 2, &mut metric2[2 * start..]);
|
||||
start += 2;
|
||||
}
|
||||
start = 0;
|
||||
while start + 3 < frame_syms {
|
||||
ft2_extract_logl_seq(&symbols, start, 4, &mut metric4[2 * start..]);
|
||||
start += 4;
|
||||
}
|
||||
|
||||
// Patch boundaries
|
||||
if 2 * frame_syms >= 206 {
|
||||
metric2[204] = metric1[204];
|
||||
metric2[205] = metric1[205];
|
||||
metric4[200] = metric2[200];
|
||||
metric4[201] = metric2[201];
|
||||
metric4[202] = metric2[202];
|
||||
metric4[203] = metric2[203];
|
||||
metric4[204] = metric1[204];
|
||||
metric4[205] = metric1[205];
|
||||
}
|
||||
|
||||
// Map to 174 data bits, selecting max-magnitude metric
|
||||
for data_sym in 0..FT2_ND {
|
||||
let frame_sym = data_sym
|
||||
+ if data_sym < 29 {
|
||||
4
|
||||
} else if data_sym < 58 {
|
||||
8
|
||||
} else {
|
||||
12
|
||||
};
|
||||
let src_bit = 2 * frame_sym;
|
||||
let dst_bit = 2 * data_sym;
|
||||
|
||||
for b in 0..2 {
|
||||
let a = metric1[src_bit + b];
|
||||
let bv = metric2[src_bit + b];
|
||||
let c = metric4[src_bit + b];
|
||||
log174[dst_bit + b] = if a.abs() >= bv.abs() && a.abs() >= c.abs() {
|
||||
a
|
||||
} else if bv.abs() >= c.abs() {
|
||||
bv
|
||||
} else {
|
||||
c
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ft2_extract_logl_seq(
|
||||
symbols: &[[Complex32; 103]; 4],
|
||||
start_sym: usize,
|
||||
n_syms: usize,
|
||||
metrics: &mut [f32],
|
||||
) {
|
||||
let n_bits = 2 * n_syms;
|
||||
let n_sequences = 1 << n_bits;
|
||||
|
||||
for bit in 0..n_bits {
|
||||
let mut max_zero = f32::NEG_INFINITY;
|
||||
let mut max_one = f32::NEG_INFINITY;
|
||||
for seq in 0..n_sequences {
|
||||
let mut sum = Complex32::new(0.0, 0.0);
|
||||
for sym in 0..n_syms {
|
||||
let shift = 2 * (n_syms - sym - 1);
|
||||
let dibit = (seq >> shift) & 0x3;
|
||||
let tone = FT4_GRAY_MAP[dibit] as usize;
|
||||
if start_sym + sym < 103 {
|
||||
sum += symbols[tone][start_sym + sym];
|
||||
}
|
||||
}
|
||||
let strength = sum.norm();
|
||||
let mask_bit = n_bits - bit - 1;
|
||||
if (seq >> mask_bit) & 1 != 0 {
|
||||
if strength > max_one {
|
||||
max_one = strength;
|
||||
}
|
||||
} else if strength > max_zero {
|
||||
max_zero = strength;
|
||||
}
|
||||
}
|
||||
if bit < metrics.len() {
|
||||
metrics[bit] = max_one - max_zero;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! Frequency-domain downsampling via IFFT.
|
||||
//!
|
||||
//! Given the full-rate raw audio, this module computes a single forward FFT of
|
||||
//! the entire buffer, then for each candidate frequency extracts a narrow band
|
||||
//! around that frequency, applies a spectral window, and inverse-FFTs to produce
|
||||
//! a complex baseband signal at a reduced sample rate (12000/NDOWN = 1333.3 Hz).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use num_complex::Complex32;
|
||||
use rustfft::FftPlanner;
|
||||
|
||||
use super::{FT2_NDOWN, FT2_SYMBOL_PERIOD_F};
|
||||
|
||||
/// Reusable scratch buffers for frequency-domain downsampling.
|
||||
pub struct DownsampleWorkspace {
|
||||
band: Vec<Complex32>,
|
||||
ifft_scratch: Vec<Complex32>,
|
||||
}
|
||||
|
||||
impl DownsampleWorkspace {
|
||||
fn new(nfft2: usize, ifft_scratch_len: usize) -> Self {
|
||||
Self {
|
||||
band: vec![Complex32::new(0.0, 0.0); nfft2],
|
||||
ifft_scratch: vec![Complex32::new(0.0, 0.0); ifft_scratch_len],
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare(&mut self, nfft2: usize, ifft_scratch_len: usize) {
|
||||
if self.band.len() != nfft2 {
|
||||
self.band.resize(nfft2, Complex32::new(0.0, 0.0));
|
||||
} else {
|
||||
self.band.fill(Complex32::new(0.0, 0.0));
|
||||
}
|
||||
|
||||
if self.ifft_scratch.len() != ifft_scratch_len {
|
||||
self.ifft_scratch
|
||||
.resize(ifft_scratch_len, Complex32::new(0.0, 0.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Downsample context holding precomputed FFT data and spectral window.
|
||||
pub struct DownsampleContext {
|
||||
/// Number of raw samples.
|
||||
nraw: usize,
|
||||
/// Length of the downsampled FFT (nraw / NDOWN).
|
||||
nfft2: usize,
|
||||
/// Frequency resolution of the raw FFT (Hz per bin).
|
||||
df: f32,
|
||||
/// Spectral extraction window (length nfft2).
|
||||
window: Vec<f32>,
|
||||
/// Full spectrum of the raw audio (nraw/2 + 1 complex bins).
|
||||
spectrum: Vec<Complex32>,
|
||||
/// IFFT plan for the downsampled length.
|
||||
ifft: std::sync::Arc<dyn rustfft::Fft<f32>>,
|
||||
/// Scratch length required by the IFFT plan.
|
||||
ifft_scratch_len: usize,
|
||||
}
|
||||
|
||||
impl DownsampleContext {
|
||||
/// Initialize the downsample context by computing the forward FFT of
|
||||
/// the raw audio and preparing the spectral window.
|
||||
///
|
||||
/// If `real_fft` and `ifft` are provided, they are reused instead of
|
||||
/// creating fresh planners. The real FFT must be a forward plan of length
|
||||
/// `nraw` and the IFFT must be an inverse plan of length `nraw / NDOWN`.
|
||||
///
|
||||
/// Returns `None` if the raw audio is too short or allocation fails.
|
||||
pub fn new(raw_audio: &[f32], sample_rate: f32) -> Option<Self> {
|
||||
Self::new_with_plans(raw_audio, sample_rate, None, None)
|
||||
}
|
||||
|
||||
/// Initialize with optional pre-built FFT plans for reuse across decode cycles.
|
||||
pub fn new_with_plans(
|
||||
raw_audio: &[f32],
|
||||
sample_rate: f32,
|
||||
real_fft: Option<Arc<dyn realfft::RealToComplex<f32>>>,
|
||||
ifft: Option<Arc<dyn rustfft::Fft<f32>>>,
|
||||
) -> Option<Self> {
|
||||
let nraw = raw_audio.len();
|
||||
if nraw == 0 {
|
||||
return None;
|
||||
}
|
||||
let nfft2 = nraw / FT2_NDOWN;
|
||||
if nfft2 == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let df = sample_rate / nraw as f32;
|
||||
|
||||
// Build spectral extraction window
|
||||
let mut window = build_spectral_window(nfft2, df);
|
||||
let inv_nfft2 = 1.0 / nfft2 as f32;
|
||||
for coeff in &mut window {
|
||||
*coeff *= inv_nfft2;
|
||||
}
|
||||
|
||||
// Forward real FFT of raw audio
|
||||
let fft = match real_fft {
|
||||
Some(f) => f,
|
||||
None => {
|
||||
let mut real_planner = realfft::RealFftPlanner::<f32>::new();
|
||||
real_planner.plan_fft_forward(nraw)
|
||||
}
|
||||
};
|
||||
let mut input = fft.make_input_vec();
|
||||
let mut output = fft.make_output_vec();
|
||||
let mut scratch = fft.make_scratch_vec();
|
||||
|
||||
input.copy_from_slice(raw_audio);
|
||||
fft.process_with_scratch(&mut input, &mut output, &mut scratch)
|
||||
.ok()?;
|
||||
|
||||
let spectrum = output;
|
||||
|
||||
// IFFT plan for downsampled length
|
||||
let ifft = match ifft {
|
||||
Some(f) => f,
|
||||
None => {
|
||||
let mut planner = FftPlanner::<f32>::new();
|
||||
planner.plan_fft_inverse(nfft2)
|
||||
}
|
||||
};
|
||||
let ifft_scratch_len = ifft.get_inplace_scratch_len();
|
||||
|
||||
Some(Self {
|
||||
nraw,
|
||||
nfft2,
|
||||
df,
|
||||
window,
|
||||
spectrum,
|
||||
ifft,
|
||||
ifft_scratch_len,
|
||||
})
|
||||
}
|
||||
|
||||
/// Number of downsampled output samples.
|
||||
pub fn nfft2(&self) -> usize {
|
||||
self.nfft2
|
||||
}
|
||||
|
||||
/// Create reusable buffers for repeated downsampling with this context.
|
||||
pub fn workspace(&self) -> DownsampleWorkspace {
|
||||
DownsampleWorkspace::new(self.nfft2, self.ifft_scratch_len)
|
||||
}
|
||||
|
||||
/// Downsample the raw audio around `freq_hz`, writing complex baseband
|
||||
/// samples into `out`. Returns the number of samples produced.
|
||||
pub fn downsample(&self, freq_hz: f32, out: &mut [Complex32]) -> usize {
|
||||
let mut workspace = self.workspace();
|
||||
self.downsample_with_workspace(freq_hz, out, &mut workspace)
|
||||
}
|
||||
|
||||
/// Downsample the raw audio using reusable scratch buffers.
|
||||
pub fn downsample_with_workspace(
|
||||
&self,
|
||||
freq_hz: f32,
|
||||
out: &mut [Complex32],
|
||||
workspace: &mut DownsampleWorkspace,
|
||||
) -> usize {
|
||||
if out.len() < self.nfft2 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
workspace.prepare(self.nfft2, self.ifft_scratch_len);
|
||||
let band = &mut workspace.band;
|
||||
let i0 = (freq_hz / self.df).round() as i32;
|
||||
let half_nraw = (self.nraw / 2) as i32;
|
||||
|
||||
// DC bin
|
||||
if i0 >= 0 && i0 <= half_nraw && (i0 as usize) < self.spectrum.len() {
|
||||
band[0] = self.spectrum[i0 as usize];
|
||||
}
|
||||
|
||||
// Positive and negative frequency bins
|
||||
for i in 1..=(self.nfft2 as i32 / 2) {
|
||||
let pos = i0 + i;
|
||||
if pos >= 0 && pos <= half_nraw && (pos as usize) < self.spectrum.len() {
|
||||
band[i as usize] = self.spectrum[pos as usize];
|
||||
}
|
||||
let neg = i0 - i;
|
||||
if neg >= 0 && neg <= half_nraw && (neg as usize) < self.spectrum.len() {
|
||||
band[(self.nfft2 as i32 - i) as usize] = self.spectrum[neg as usize];
|
||||
}
|
||||
}
|
||||
|
||||
// Apply spectral window
|
||||
for (b, &w) in band.iter_mut().zip(self.window.iter()) {
|
||||
*b *= w;
|
||||
}
|
||||
|
||||
// Inverse FFT (in-place)
|
||||
self.ifft
|
||||
.process_with_scratch(band, &mut workspace.ifft_scratch);
|
||||
|
||||
out[..self.nfft2].copy_from_slice(band);
|
||||
self.nfft2
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the spectral window used during band extraction.
|
||||
///
|
||||
/// The window has a raised-cosine transition, a flat passband covering
|
||||
/// the FT2 signal bandwidth (4 * baud), and is circularly shifted by
|
||||
/// one baud rate worth of bins.
|
||||
fn build_spectral_window(nfft2: usize, df: f32) -> Vec<f32> {
|
||||
let baud = 1.0 / FT2_SYMBOL_PERIOD_F;
|
||||
let iwt = ((0.5 * baud) / df) as usize;
|
||||
let iwf = ((4.0 * baud) / df) as usize;
|
||||
let iws = (baud / df) as usize;
|
||||
|
||||
let mut window = vec![0.0f32; nfft2];
|
||||
|
||||
if iwt == 0 {
|
||||
return window;
|
||||
}
|
||||
|
||||
// Raised-cosine leading edge
|
||||
for (i, w) in window.iter_mut().enumerate().take(iwt.min(nfft2)) {
|
||||
*w = 0.5 * (1.0 + (std::f32::consts::PI * (iwt - 1 - i) as f32 / iwt as f32).cos());
|
||||
}
|
||||
|
||||
// Flat passband
|
||||
for w in window
|
||||
.iter_mut()
|
||||
.skip(iwt)
|
||||
.take((iwt + iwf).min(nfft2) - iwt)
|
||||
{
|
||||
*w = 1.0;
|
||||
}
|
||||
|
||||
// Raised-cosine trailing edge
|
||||
for (i, w) in window
|
||||
.iter_mut()
|
||||
.enumerate()
|
||||
.take((2 * iwt + iwf).min(nfft2))
|
||||
.skip(iwt + iwf)
|
||||
{
|
||||
*w = 0.5 * (1.0 + (std::f32::consts::PI * (i - (iwt + iwf)) as f32 / iwt as f32).cos());
|
||||
}
|
||||
|
||||
// Circular shift by iws bins
|
||||
if iws > 0 && iws < nfft2 {
|
||||
window.rotate_left(iws);
|
||||
}
|
||||
|
||||
window
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn spectral_window_length() {
|
||||
let w = build_spectral_window(5000, 12000.0 / 45000.0);
|
||||
assert_eq!(w.len(), 5000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spectral_window_nonnegative() {
|
||||
let w = build_spectral_window(5000, 12000.0 / 45000.0);
|
||||
for &v in &w {
|
||||
assert!(v >= 0.0, "Window value should be non-negative: {}", v);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downsample_context_creation() {
|
||||
let raw = vec![0.0f32; 45000];
|
||||
let ctx = DownsampleContext::new(&raw, 12000.0);
|
||||
assert!(ctx.is_some());
|
||||
let ctx = ctx.unwrap();
|
||||
assert_eq!(ctx.nfft2(), 45000 / 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downsample_produces_samples() {
|
||||
let raw = vec![0.0f32; 45000];
|
||||
let ctx = DownsampleContext::new(&raw, 12000.0).unwrap();
|
||||
let nfft2 = ctx.nfft2();
|
||||
let mut out = vec![Complex32::new(0.0, 0.0); nfft2];
|
||||
let n = ctx.downsample(1000.0, &mut out);
|
||||
assert_eq!(n, nfft2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downsample_output_too_small() {
|
||||
let raw = vec![0.0f32; 45000];
|
||||
let ctx = DownsampleContext::new(&raw, 12000.0).unwrap();
|
||||
let mut out = vec![Complex32::new(0.0, 0.0); 10];
|
||||
let n = ctx.downsample(1000.0, &mut out);
|
||||
assert_eq!(n, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_audio_returns_none() {
|
||||
let raw: Vec<f32> = Vec::new();
|
||||
assert!(DownsampleContext::new(&raw, 12000.0).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,846 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! FT2 pipeline orchestration.
|
||||
//!
|
||||
//! Implements the full FT2 decode flow: accumulate raw audio, find frequency
|
||||
//! peaks in the averaged spectrum, downsample each candidate, compute 2D sync
|
||||
//! scores, extract bit metrics, and run multi-pass LDPC + OSD decode.
|
||||
|
||||
pub mod bitmetrics;
|
||||
pub(crate) mod decode;
|
||||
pub mod downsample;
|
||||
pub mod sync;
|
||||
|
||||
pub(crate) use self::decode::{ft2_extract_likelihood, ft2_sync_score};
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use num_complex::Complex32;
|
||||
use realfft::RealFftPlanner;
|
||||
use rustfft::FftPlanner;
|
||||
|
||||
use self::bitmetrics::BitMetricsWorkspace;
|
||||
use self::downsample::{DownsampleContext, DownsampleWorkspace};
|
||||
use self::sync::{prepare_sync_waveforms, sync2d_score, SyncWaveforms};
|
||||
use crate::common::decode::{verify_crc_and_build_message, FtxMessage};
|
||||
use crate::common::protocol::*;
|
||||
|
||||
// FT2 DSP constants
|
||||
pub const FT2_NDOWN: usize = 9;
|
||||
pub const FT2_NFFT1: usize = 1152;
|
||||
pub const FT2_NH1: usize = FT2_NFFT1 / 2;
|
||||
pub const FT2_NSTEP: usize = 288;
|
||||
pub const FT2_NMAX: usize = 45000;
|
||||
pub const FT2_MAX_RAW_CANDIDATES: usize = 96;
|
||||
pub const FT2_MAX_SCAN_HITS: usize = 128;
|
||||
pub const FT2_SYNC_TWEAK_MIN: i32 = -16;
|
||||
pub const FT2_SYNC_TWEAK_MAX: i32 = 16;
|
||||
pub const FT2_NSS: usize = FT2_NSTEP / FT2_NDOWN;
|
||||
pub const FT2_FRAME_SYMBOLS: usize = FT2_NN - FT2_NR;
|
||||
pub const FT2_FRAME_SAMPLES: usize = FT2_FRAME_SYMBOLS * FT2_NSS;
|
||||
pub const FT2_SYMBOL_PERIOD_F: f32 = FT2_SYMBOL_PERIOD;
|
||||
|
||||
/// Frequency offset applied to FT2 candidates.
|
||||
pub fn ft2_frequency_offset_hz() -> f32 {
|
||||
-1.5 / FT2_SYMBOL_PERIOD_F
|
||||
}
|
||||
|
||||
/// Generate FT2 tone sequence from payload data.
|
||||
///
|
||||
/// FT2 uses the FT4 framing with a doubled symbol rate.
|
||||
pub fn ft2_encode(payload: &[u8], tones: &mut [u8]) {
|
||||
crate::ft4::ft4_encode(payload, tones);
|
||||
}
|
||||
|
||||
/// Raw frequency peak candidate from the averaged power spectrum.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct RawCandidate {
|
||||
pub freq_hz: f32,
|
||||
pub score: f32,
|
||||
}
|
||||
|
||||
/// Scan hit with refined sync parameters.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct ScanHit {
|
||||
pub freq_hz: f32,
|
||||
pub snr0: f32,
|
||||
pub sync_score: f32,
|
||||
pub start: i32,
|
||||
pub idf: i32,
|
||||
}
|
||||
|
||||
/// Statistics from the scan phase.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct ScanStats {
|
||||
pub peaks_found: usize,
|
||||
pub hits_found: usize,
|
||||
pub best_peak_score: f32,
|
||||
pub best_sync_score: f32,
|
||||
}
|
||||
|
||||
/// Failure stage classification for diagnostics.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FailStage {
|
||||
None,
|
||||
RefinedSync,
|
||||
FreqRange,
|
||||
FinalDownsample,
|
||||
BitMetrics,
|
||||
SyncQual,
|
||||
Ldpc,
|
||||
Crc,
|
||||
Unpack,
|
||||
}
|
||||
|
||||
/// Per-pass diagnostic information.
|
||||
#[derive(Clone)]
|
||||
pub struct PassDiag {
|
||||
pub ntype: [i32; 5],
|
||||
pub nharderror: [i32; 5],
|
||||
pub dmin: [f32; 5],
|
||||
}
|
||||
|
||||
impl Default for PassDiag {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
ntype: [0; 5],
|
||||
nharderror: [-1; 5],
|
||||
dmin: [f32::INFINITY; 5],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decoded FT2 result with timing and frequency metadata.
|
||||
#[derive(Clone)]
|
||||
pub struct Ft2DecodeResult {
|
||||
pub message: FtxMessage,
|
||||
pub dt_s: f32,
|
||||
pub freq_hz: f32,
|
||||
pub snr_db: f32,
|
||||
}
|
||||
|
||||
/// FT2 pipeline state. Accumulates raw audio and runs the full decode flow.
|
||||
pub struct Ft2Pipeline {
|
||||
sample_rate: f32,
|
||||
raw_audio: Vec<f32>,
|
||||
raw_capacity: usize,
|
||||
waveforms: SyncWaveforms,
|
||||
peak_search: PeakSearchWorkspace,
|
||||
// Cached FFT plans reused across decode cycles
|
||||
ds_real_fft: Arc<dyn realfft::RealToComplex<f32>>,
|
||||
ds_ifft: Arc<dyn rustfft::Fft<f32>>,
|
||||
}
|
||||
|
||||
struct Ft2DecodeWorkspace {
|
||||
downsample: DownsampleWorkspace,
|
||||
downsample_a: Vec<Complex32>,
|
||||
downsample_b: Vec<Complex32>,
|
||||
signal: Vec<Complex32>,
|
||||
bitmetrics: BitMetricsWorkspace,
|
||||
}
|
||||
|
||||
impl Ft2DecodeWorkspace {
|
||||
fn new(ctx: &DownsampleContext) -> Self {
|
||||
let nfft2 = ctx.nfft2();
|
||||
Self {
|
||||
downsample: ctx.workspace(),
|
||||
downsample_a: vec![Complex32::new(0.0, 0.0); nfft2],
|
||||
downsample_b: vec![Complex32::new(0.0, 0.0); nfft2],
|
||||
signal: vec![Complex32::new(0.0, 0.0); FT2_FRAME_SAMPLES],
|
||||
bitmetrics: BitMetricsWorkspace::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PeakSearchWorkspace {
|
||||
window: Vec<f32>,
|
||||
fft: std::sync::Arc<dyn realfft::RealToComplex<f32>>,
|
||||
fft_input: Vec<f32>,
|
||||
fft_output: Vec<Complex32>,
|
||||
fft_scratch: Vec<Complex32>,
|
||||
avg: Vec<f32>,
|
||||
smooth: Vec<f32>,
|
||||
baseline: Vec<f32>,
|
||||
}
|
||||
|
||||
impl PeakSearchWorkspace {
|
||||
fn new() -> Self {
|
||||
let window = nuttall_window(FT2_NFFT1);
|
||||
let mut planner = RealFftPlanner::<f32>::new();
|
||||
let fft = planner.plan_fft_forward(FT2_NFFT1);
|
||||
let fft_input = fft.make_input_vec();
|
||||
let fft_output = fft.make_output_vec();
|
||||
let fft_scratch = fft.make_scratch_vec();
|
||||
|
||||
Self {
|
||||
window,
|
||||
fft,
|
||||
fft_input,
|
||||
fft_output,
|
||||
fft_scratch,
|
||||
avg: vec![0.0; FT2_NH1],
|
||||
smooth: vec![0.0; FT2_NH1],
|
||||
baseline: vec![0.0; FT2_NH1],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Ft2Pipeline {
|
||||
/// Create a new FT2 pipeline for the given sample rate.
|
||||
pub fn new(sample_rate: i32) -> Self {
|
||||
// Pre-build FFT plans for the downsample context (reused every decode cycle)
|
||||
let nfft2 = FT2_NMAX / FT2_NDOWN;
|
||||
let mut real_planner = RealFftPlanner::<f32>::new();
|
||||
let ds_real_fft = real_planner.plan_fft_forward(FT2_NMAX);
|
||||
let mut fft_planner = FftPlanner::<f32>::new();
|
||||
let ds_ifft = fft_planner.plan_fft_inverse(nfft2);
|
||||
|
||||
Self {
|
||||
sample_rate: sample_rate as f32,
|
||||
raw_audio: Vec::with_capacity(FT2_NMAX),
|
||||
raw_capacity: FT2_NMAX,
|
||||
waveforms: prepare_sync_waveforms(),
|
||||
peak_search: PeakSearchWorkspace::new(),
|
||||
ds_real_fft,
|
||||
ds_ifft,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset the pipeline, clearing all accumulated audio.
|
||||
pub fn reset(&mut self) {
|
||||
self.raw_audio.clear();
|
||||
}
|
||||
|
||||
/// Accumulate raw audio samples. Returns true when the buffer is full.
|
||||
pub fn accumulate(&mut self, samples: &[f32]) -> bool {
|
||||
let remaining = self.raw_capacity.saturating_sub(self.raw_audio.len());
|
||||
if remaining > 0 {
|
||||
let n = remaining.min(samples.len());
|
||||
self.raw_audio.extend_from_slice(&samples[..n]);
|
||||
}
|
||||
self.raw_audio.len() >= self.raw_capacity
|
||||
}
|
||||
|
||||
/// Returns true when enough audio has been accumulated for decoding.
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.raw_audio.len() >= self.raw_capacity
|
||||
}
|
||||
|
||||
/// Number of raw audio samples accumulated so far.
|
||||
pub fn raw_len(&self) -> usize {
|
||||
self.raw_audio.len()
|
||||
}
|
||||
|
||||
/// Run the full FT2 decode pipeline. Returns decoded messages.
|
||||
pub fn decode(&mut self, max_results: usize) -> Vec<Ft2DecodeResult> {
|
||||
if self.raw_audio.len() < FT2_NFFT1 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let ctx = match DownsampleContext::new_with_plans(
|
||||
&self.raw_audio,
|
||||
self.sample_rate,
|
||||
Some(Arc::clone(&self.ds_real_fft)),
|
||||
Some(Arc::clone(&self.ds_ifft)),
|
||||
) {
|
||||
Some(ctx) => ctx,
|
||||
None => return Vec::new(),
|
||||
};
|
||||
|
||||
let mut workspace = Ft2DecodeWorkspace::new(&ctx);
|
||||
let hits = self.find_scan_hits(&ctx, &mut workspace);
|
||||
if hits.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut results = Vec::new();
|
||||
let mut seen_hashes: Vec<(u16, [u8; FTX_PAYLOAD_LENGTH_BYTES])> = Vec::new();
|
||||
|
||||
for hit in &hits {
|
||||
if results.len() >= max_results {
|
||||
break;
|
||||
}
|
||||
if let Some(result) = self.decode_hit(&ctx, hit, &mut workspace) {
|
||||
// Dedup
|
||||
let dominated = seen_hashes
|
||||
.iter()
|
||||
.any(|(h, p)| *h == result.message.hash && *p == result.message.payload);
|
||||
if dominated {
|
||||
continue;
|
||||
}
|
||||
seen_hashes.push((result.message.hash, result.message.payload));
|
||||
results.push(result);
|
||||
}
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
/// Find frequency peaks from averaged power spectrum.
|
||||
fn find_frequency_peaks(&mut self) -> Vec<RawCandidate> {
|
||||
if self.raw_audio.len() < FT2_NFFT1 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let fs = self.sample_rate;
|
||||
let df = fs / FT2_NFFT1 as f32;
|
||||
let n_frames = 1 + (self.raw_audio.len() - FT2_NFFT1) / FT2_NSTEP;
|
||||
let PeakSearchWorkspace {
|
||||
window,
|
||||
fft,
|
||||
fft_input,
|
||||
fft_output,
|
||||
fft_scratch,
|
||||
avg,
|
||||
smooth,
|
||||
baseline,
|
||||
} = &mut self.peak_search;
|
||||
|
||||
avg.fill(0.0);
|
||||
smooth.fill(0.0);
|
||||
baseline.fill(0.0);
|
||||
|
||||
for frame in 0..n_frames {
|
||||
let start = frame * FT2_NSTEP;
|
||||
let input = &self.raw_audio[start..(start + FT2_NFFT1)];
|
||||
for (dst, (&sample, &coeff)) in
|
||||
fft_input.iter_mut().zip(input.iter().zip(window.iter()))
|
||||
{
|
||||
*dst = sample * coeff;
|
||||
}
|
||||
fft.process_with_scratch(fft_input, fft_output, fft_scratch)
|
||||
.expect("FFT failed");
|
||||
|
||||
for (bin, c) in fft_output.iter().enumerate().take(FT2_NH1).skip(1) {
|
||||
avg[bin] += c.norm_sqr();
|
||||
}
|
||||
}
|
||||
|
||||
let inv_n_frames = 1.0 / n_frames as f32;
|
||||
for v in avg.iter_mut().take(FT2_NH1).skip(1) {
|
||||
*v *= inv_n_frames;
|
||||
}
|
||||
|
||||
// Smooth with 15-point moving average
|
||||
if FT2_NH1 > 16 {
|
||||
let mut sum: f32 = avg[1..16].iter().sum();
|
||||
for bin in 8..FT2_NH1.saturating_sub(8) {
|
||||
smooth[bin] = sum / 15.0;
|
||||
if bin + 8 < FT2_NH1 {
|
||||
sum += avg[bin + 8] - avg[bin - 7];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Baseline with 63-point moving average
|
||||
if FT2_NH1 > 64 {
|
||||
let mut sum: f32 = smooth[1..64].iter().sum();
|
||||
for bin in 32..FT2_NH1.saturating_sub(32) {
|
||||
baseline[bin] = sum / 63.0 + 1e-9;
|
||||
if bin + 32 < FT2_NH1 {
|
||||
sum += smooth[bin + 32] - smooth[bin - 31];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find peaks
|
||||
let min_bin = (200.0 / df).round() as usize;
|
||||
let max_bin = (4910.0 / df).round() as usize;
|
||||
let mut candidates = Vec::with_capacity(FT2_MAX_RAW_CANDIDATES);
|
||||
|
||||
let mut bin = min_bin + 1;
|
||||
while bin < max_bin.saturating_sub(1) && candidates.len() < FT2_MAX_RAW_CANDIDATES {
|
||||
if baseline[bin] <= 0.0 {
|
||||
bin += 1;
|
||||
continue;
|
||||
}
|
||||
let value = smooth[bin] / baseline[bin];
|
||||
if value < 1.03 {
|
||||
bin += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let left = smooth[bin.saturating_sub(1)] / baseline[bin.saturating_sub(1)].max(1e-9);
|
||||
let right = if bin + 1 < FT2_NH1 {
|
||||
smooth[bin + 1] / baseline[bin + 1].max(1e-9)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
if value < left || value < right {
|
||||
bin += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let den = left - 2.0 * value + right;
|
||||
let delta = if den.abs() > 1e-6 {
|
||||
0.5 * (left - right) / den
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let freq_hz = (bin as f32 + delta) * df + ft2_frequency_offset_hz();
|
||||
if !(200.0..=4910.0).contains(&freq_hz) {
|
||||
bin += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
candidates.push(RawCandidate {
|
||||
freq_hz,
|
||||
score: value,
|
||||
});
|
||||
bin += 1;
|
||||
}
|
||||
|
||||
// Sort by score descending
|
||||
candidates.sort_by(|a, b| {
|
||||
b.score
|
||||
.partial_cmp(&a.score)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
candidates
|
||||
}
|
||||
|
||||
/// Find scan hits by downsampling each frequency peak and computing sync scores.
|
||||
fn find_scan_hits(
|
||||
&mut self,
|
||||
ctx: &DownsampleContext,
|
||||
workspace: &mut Ft2DecodeWorkspace,
|
||||
) -> Vec<ScanHit> {
|
||||
let peaks = self.find_frequency_peaks();
|
||||
if peaks.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut hits = Vec::new();
|
||||
|
||||
for peak in &peaks {
|
||||
if hits.len() >= FT2_MAX_SCAN_HITS {
|
||||
break;
|
||||
}
|
||||
|
||||
let produced = ctx.downsample_with_workspace(
|
||||
peak.freq_hz,
|
||||
&mut workspace.downsample_a,
|
||||
&mut workspace.downsample,
|
||||
);
|
||||
if produced == 0 {
|
||||
continue;
|
||||
}
|
||||
normalize_downsampled(&mut workspace.downsample_a[..produced], produced);
|
||||
|
||||
// Coarse search
|
||||
let mut best_score: f32 = -1.0;
|
||||
let mut best_start: i32 = 0;
|
||||
let mut best_idf: i32 = 0;
|
||||
|
||||
let mut idf = -12i32;
|
||||
while idf <= 12 {
|
||||
let mut start = -688i32;
|
||||
while start <= 2024 {
|
||||
let score = sync2d_score(
|
||||
&workspace.downsample_a[..produced],
|
||||
start,
|
||||
idf,
|
||||
&self.waveforms,
|
||||
);
|
||||
if score > best_score {
|
||||
best_score = score;
|
||||
best_start = start;
|
||||
best_idf = idf;
|
||||
}
|
||||
start += 4;
|
||||
}
|
||||
idf += 3;
|
||||
}
|
||||
|
||||
if best_score < 0.40 {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fine refinement
|
||||
for idf in (best_idf - 4)..=(best_idf + 4) {
|
||||
if !(FT2_SYNC_TWEAK_MIN..=FT2_SYNC_TWEAK_MAX).contains(&idf) {
|
||||
continue;
|
||||
}
|
||||
for start in (best_start - 5)..=(best_start + 5) {
|
||||
let score = sync2d_score(
|
||||
&workspace.downsample_a[..produced],
|
||||
start,
|
||||
idf,
|
||||
&self.waveforms,
|
||||
);
|
||||
if score > best_score {
|
||||
best_score = score;
|
||||
best_start = start;
|
||||
best_idf = idf;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if best_score < 0.40 {
|
||||
continue;
|
||||
}
|
||||
|
||||
hits.push(ScanHit {
|
||||
freq_hz: peak.freq_hz,
|
||||
snr0: peak.score - 1.0,
|
||||
sync_score: best_score,
|
||||
start: best_start,
|
||||
idf: best_idf,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by sync score descending
|
||||
hits.sort_by(|a, b| {
|
||||
b.sync_score
|
||||
.partial_cmp(&a.sync_score)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
hits
|
||||
}
|
||||
|
||||
/// Attempt to decode a single scan hit through the full pipeline.
|
||||
fn decode_hit(
|
||||
&self,
|
||||
ctx: &DownsampleContext,
|
||||
hit: &ScanHit,
|
||||
workspace: &mut Ft2DecodeWorkspace,
|
||||
) -> Option<Ft2DecodeResult> {
|
||||
// Initial downsample for sync refinement
|
||||
let produced = ctx.downsample_with_workspace(
|
||||
hit.freq_hz,
|
||||
&mut workspace.downsample_a,
|
||||
&mut workspace.downsample,
|
||||
);
|
||||
if produced == 0 {
|
||||
return None;
|
||||
}
|
||||
normalize_downsampled(&mut workspace.downsample_a[..produced], produced);
|
||||
|
||||
// Refine sync
|
||||
let mut best_score: f32 = -1.0;
|
||||
let mut best_start = hit.start;
|
||||
let mut best_idf = hit.idf;
|
||||
|
||||
for idf in (hit.idf - 4)..=(hit.idf + 4) {
|
||||
if !(FT2_SYNC_TWEAK_MIN..=FT2_SYNC_TWEAK_MAX).contains(&idf) {
|
||||
continue;
|
||||
}
|
||||
for start in (hit.start - 5)..=(hit.start + 5) {
|
||||
let score = sync2d_score(
|
||||
&workspace.downsample_a[..produced],
|
||||
start,
|
||||
idf,
|
||||
&self.waveforms,
|
||||
);
|
||||
if score > best_score {
|
||||
best_score = score;
|
||||
best_start = start;
|
||||
best_idf = idf;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if best_score < 0.55 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Frequency correction
|
||||
let corrected_freq_hz = hit.freq_hz + best_idf as f32;
|
||||
if corrected_freq_hz <= 10.0 || corrected_freq_hz >= 4990.0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Final downsample at corrected frequency
|
||||
let produced2 = ctx.downsample_with_workspace(
|
||||
corrected_freq_hz,
|
||||
&mut workspace.downsample_b,
|
||||
&mut workspace.downsample,
|
||||
);
|
||||
if produced2 == 0 {
|
||||
return None;
|
||||
}
|
||||
normalize_downsampled(&mut workspace.downsample_b[..produced2], FT2_FRAME_SAMPLES);
|
||||
|
||||
// Extract signal region
|
||||
extract_signal_region(
|
||||
&workspace.downsample_b[..produced2],
|
||||
best_start,
|
||||
&mut workspace.signal,
|
||||
);
|
||||
|
||||
// Extract bit metrics
|
||||
let bitmetrics = workspace.bitmetrics.extract(&workspace.signal)?;
|
||||
|
||||
// Sync quality check using known Costas bit patterns
|
||||
let sync_bits_a: [u8; 8] = [0, 0, 0, 1, 1, 0, 1, 1];
|
||||
let sync_bits_b: [u8; 8] = [0, 1, 0, 0, 1, 1, 1, 0];
|
||||
let sync_bits_c: [u8; 8] = [1, 1, 1, 0, 0, 1, 0, 0];
|
||||
let sync_bits_d: [u8; 8] = [1, 0, 1, 1, 0, 0, 0, 1];
|
||||
let mut sync_qual = 0;
|
||||
for i in 0..8 {
|
||||
sync_qual += if (bitmetrics[i][0] >= 0.0) as u8 == sync_bits_a[i] {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
sync_qual += if (bitmetrics[66 + i][0] >= 0.0) as u8 == sync_bits_b[i] {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
sync_qual += if (bitmetrics[132 + i][0] >= 0.0) as u8 == sync_bits_c[i] {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
sync_qual += if (bitmetrics[198 + i][0] >= 0.0) as u8 == sync_bits_d[i] {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
}
|
||||
if sync_qual < 9 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Build 5 LLR passes from the 3 metric scales
|
||||
let mut llr_passes = [[0.0f32; FTX_LDPC_N]; 5];
|
||||
for i in 0..58 {
|
||||
llr_passes[0][i] = bitmetrics[8 + i][0];
|
||||
llr_passes[0][58 + i] = bitmetrics[74 + i][0];
|
||||
llr_passes[0][116 + i] = bitmetrics[140 + i][0];
|
||||
|
||||
llr_passes[1][i] = bitmetrics[8 + i][1];
|
||||
llr_passes[1][58 + i] = bitmetrics[74 + i][1];
|
||||
llr_passes[1][116 + i] = bitmetrics[140 + i][1];
|
||||
|
||||
llr_passes[2][i] = bitmetrics[8 + i][2];
|
||||
llr_passes[2][58 + i] = bitmetrics[74 + i][2];
|
||||
llr_passes[2][116 + i] = bitmetrics[140 + i][2];
|
||||
}
|
||||
|
||||
// Scale and derive combined passes
|
||||
let [ref mut pass0, ref mut pass1, ref mut pass2, ref mut pass3, ref mut pass4] =
|
||||
llr_passes;
|
||||
for v in pass0.iter_mut() {
|
||||
*v *= 2.83;
|
||||
}
|
||||
for v in pass1.iter_mut() {
|
||||
*v *= 2.83;
|
||||
}
|
||||
for v in pass2.iter_mut() {
|
||||
*v *= 2.83;
|
||||
}
|
||||
for ((&a, &b), (&c, (p3, p4))) in pass0
|
||||
.iter()
|
||||
.zip(pass1.iter())
|
||||
.zip(pass2.iter().zip(pass3.iter_mut().zip(pass4.iter_mut())))
|
||||
{
|
||||
// Pass 3: max-abs metric
|
||||
*p3 = if a.abs() >= b.abs() && a.abs() >= c.abs() {
|
||||
a
|
||||
} else if b.abs() >= c.abs() {
|
||||
b
|
||||
} else {
|
||||
c
|
||||
};
|
||||
|
||||
// Pass 4: average
|
||||
*p4 = (a + b + c) / 3.0;
|
||||
}
|
||||
|
||||
// Multi-pass LDPC decode using full BP+OSD decoder
|
||||
let mut ok = false;
|
||||
let mut message = FtxMessage::default();
|
||||
let mut apmask = [0u8; FTX_LDPC_N];
|
||||
|
||||
for llr_pass in &llr_passes {
|
||||
if ok {
|
||||
break;
|
||||
}
|
||||
let mut log174 = *llr_pass;
|
||||
|
||||
let mut message91 = [0u8; FTX_LDPC_K];
|
||||
let mut cw = [0u8; FTX_LDPC_N];
|
||||
let mut ntype = 0i32;
|
||||
let mut nharderror = -1i32;
|
||||
let mut dmin = 0.0f32;
|
||||
|
||||
crate::common::osd::ft2_decode174_91_osd(
|
||||
&mut log174,
|
||||
FTX_LDPC_K,
|
||||
4,
|
||||
3,
|
||||
&mut apmask,
|
||||
&mut message91,
|
||||
&mut cw,
|
||||
&mut ntype,
|
||||
&mut nharderror,
|
||||
&mut dmin,
|
||||
);
|
||||
|
||||
if ntype > 0 && nharderror >= 0 {
|
||||
if let Some(msg) = verify_crc_and_build_message(&cw, true) {
|
||||
message = msg;
|
||||
ok = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !ok {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Compute refined timing via parabolic interpolation
|
||||
let sm1 = sync2d_score(
|
||||
&workspace.downsample_a[..produced],
|
||||
best_start - 1,
|
||||
best_idf,
|
||||
&self.waveforms,
|
||||
);
|
||||
let sp1 = sync2d_score(
|
||||
&workspace.downsample_a[..produced],
|
||||
best_start + 1,
|
||||
best_idf,
|
||||
&self.waveforms,
|
||||
);
|
||||
let mut xstart = best_start as f32;
|
||||
let den = sm1 - 2.0 * best_score + sp1;
|
||||
if den.abs() > 1e-6 {
|
||||
xstart += 0.5 * (sm1 - sp1) / den;
|
||||
}
|
||||
|
||||
let dt_s = xstart / (12000.0 / FT2_NDOWN as f32) - 0.5;
|
||||
let snr_db = if hit.snr0 > 0.0 {
|
||||
(10.0 * hit.snr0.log10() - 13.0).max(-21.0)
|
||||
} else {
|
||||
-21.0
|
||||
};
|
||||
|
||||
Some(Ft2DecodeResult {
|
||||
message,
|
||||
dt_s,
|
||||
freq_hz: corrected_freq_hz,
|
||||
snr_db,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute a Nuttall window of length `n`.
|
||||
fn nuttall_window(n: usize) -> Vec<f32> {
|
||||
let a0: f32 = 0.355768;
|
||||
let a1: f32 = 0.487396;
|
||||
let a2: f32 = 0.144232;
|
||||
let a3: f32 = 0.012604;
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
let phase = 2.0 * std::f32::consts::PI * i as f32 / (n - 1) as f32;
|
||||
a0 - a1 * phase.cos() + a2 * (2.0 * phase).cos() - a3 * (3.0 * phase).cos()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Normalize complex downsampled signal to unit power.
|
||||
fn normalize_downsampled(samples: &mut [Complex32], ref_count: usize) {
|
||||
let power: f32 = samples.iter().map(|s| s.norm_sqr()).sum();
|
||||
if power <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let rc = if ref_count == 0 {
|
||||
samples.len()
|
||||
} else {
|
||||
ref_count
|
||||
};
|
||||
let scale = (rc as f32 / power).sqrt();
|
||||
for s in samples.iter_mut() {
|
||||
*s *= scale;
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract a signal region starting at `start` into `out_signal`.
|
||||
fn extract_signal_region(input: &[Complex32], start: i32, out_signal: &mut [Complex32]) {
|
||||
out_signal.fill(Complex32::new(0.0, 0.0));
|
||||
|
||||
let src_start = start.max(0) as usize;
|
||||
let dst_start = (-start).max(0) as usize;
|
||||
if dst_start >= out_signal.len() || src_start >= input.len() {
|
||||
return;
|
||||
}
|
||||
|
||||
let copy_len = (input.len() - src_start).min(out_signal.len() - dst_start);
|
||||
out_signal[dst_start..(dst_start + copy_len)]
|
||||
.copy_from_slice(&input[src_start..(src_start + copy_len)]);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn nuttall_window_length() {
|
||||
let w = nuttall_window(64);
|
||||
assert_eq!(w.len(), 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nuttall_window_symmetric() {
|
||||
let w = nuttall_window(128);
|
||||
for i in 0..64 {
|
||||
assert!(
|
||||
(w[i] - w[127 - i]).abs() < 1e-6,
|
||||
"Window not symmetric at index {}",
|
||||
i
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipeline_accumulate() {
|
||||
let mut pipe = Ft2Pipeline::new(12000);
|
||||
let samples = vec![0.0f32; 1000];
|
||||
assert!(!pipe.accumulate(&samples));
|
||||
assert_eq!(pipe.raw_len(), 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipeline_ready() {
|
||||
let mut pipe = Ft2Pipeline::new(12000);
|
||||
let samples = vec![0.0f32; FT2_NMAX];
|
||||
assert!(pipe.accumulate(&samples));
|
||||
assert!(pipe.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_downsampled_zero_power() {
|
||||
let mut samples = vec![Complex32::new(0.0, 0.0); 16];
|
||||
normalize_downsampled(&mut samples, 16);
|
||||
// Should not crash or produce NaN
|
||||
for s in &samples {
|
||||
assert!(!s.re.is_nan());
|
||||
assert!(!s.im.is_nan());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode174_to_bits_all_zeros() {
|
||||
let a91 = [0u8; FTX_LDPC_K_BYTES];
|
||||
let cw = crate::common::encode::encode174_to_bits(&a91);
|
||||
for &b in &cw {
|
||||
assert_eq!(b, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ft2_encode_matches_ft4() {
|
||||
let payload = [0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC, 0xDE, 0xF0, 0x11, 0x20];
|
||||
let mut tones_ft4 = [0u8; FT4_NN];
|
||||
let mut tones_ft2 = [0u8; FT4_NN];
|
||||
crate::ft4::ft4_encode(&payload, &mut tones_ft4);
|
||||
ft2_encode(&payload, &mut tones_ft2);
|
||||
assert_eq!(tones_ft4, tones_ft2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! 2D sync scoring with complex Costas reference waveforms.
|
||||
//!
|
||||
//! Prepares reference sync waveforms from the FT4 Costas pattern and frequency
|
||||
//! tweak phasors, then correlates downsampled complex symbols against the
|
||||
//! reference across time and frequency offsets.
|
||||
|
||||
use num_complex::Complex32;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use crate::common::constants::FT4_COSTAS_PATTERN;
|
||||
|
||||
use super::{FT2_NDOWN, FT2_NSS, FT2_SYMBOL_PERIOD_F, FT2_SYNC_TWEAK_MAX, FT2_SYNC_TWEAK_MIN};
|
||||
|
||||
/// Number of frequency tweak entries.
|
||||
const NUM_TWEAKS: usize = (FT2_SYNC_TWEAK_MAX - FT2_SYNC_TWEAK_MIN) as usize + 1;
|
||||
const SYNC_GROUP_COUNT: usize = 4;
|
||||
const SYNC_SAMPLES: usize = 64;
|
||||
const SAMPLE_STRIDE: usize = 2;
|
||||
const GROUP_STRIDE: i32 = 33 * FT2_NSS as i32;
|
||||
const GROUP_LAST_SAMPLE_OFFSET: i32 = SAMPLE_STRIDE as i32 * (SYNC_SAMPLES as i32 - 1);
|
||||
const FRAME_LAST_SAMPLE_OFFSET: i32 = 3 * GROUP_STRIDE + GROUP_LAST_SAMPLE_OFFSET;
|
||||
|
||||
/// Precomputed sync and frequency-tweak waveforms.
|
||||
pub struct SyncWaveforms {
|
||||
/// Complex reference waveforms for each of the 4 Costas sync groups.
|
||||
/// Each group has 64 samples (4 tones * 16 samples per half-symbol).
|
||||
pub sync_wave: [[Complex32; 64]; 4],
|
||||
/// Frequency tweak phasors for each integer frequency offset.
|
||||
/// Index by `idf - FT2_SYNC_TWEAK_MIN`.
|
||||
pub tweak_wave: [[Complex32; 64]; NUM_TWEAKS],
|
||||
}
|
||||
|
||||
/// Prepare complex reference waveforms for sync scoring.
|
||||
///
|
||||
/// For each of the 4 Costas sync groups, we generate the expected complex
|
||||
/// signal using continuous-phase tone generation at the downsampled rate.
|
||||
/// We also generate frequency-tweak phasors for fine frequency searching.
|
||||
pub fn prepare_sync_waveforms() -> SyncWaveforms {
|
||||
let fs_down = 12000.0f32 / FT2_NDOWN as f32;
|
||||
let nss = FT2_SYMBOL_PERIOD_F * fs_down;
|
||||
|
||||
let mut sync_wave = [[Complex32::new(0.0, 0.0); 64]; 4];
|
||||
let mut tweak_wave = [[Complex32::new(0.0, 0.0); 64]; NUM_TWEAKS];
|
||||
|
||||
// Build sync reference waveforms (continuous phase across tones)
|
||||
for group in 0..4 {
|
||||
let mut idx = 0usize;
|
||||
let mut phase = 0.0f32;
|
||||
for &costas_tone in FT4_COSTAS_PATTERN[group].iter() {
|
||||
let tone = costas_tone as f32;
|
||||
let dphase = 4.0 * std::f32::consts::PI * tone / nss;
|
||||
let half_nss = (nss / 2.0) as usize;
|
||||
for _step in 0..half_nss {
|
||||
if idx >= 64 {
|
||||
break;
|
||||
}
|
||||
sync_wave[group][idx] = Complex32::new(phase.cos(), phase.sin());
|
||||
phase = (phase + dphase) % (2.0 * std::f32::consts::PI);
|
||||
idx += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build frequency tweak phasors
|
||||
for idf in FT2_SYNC_TWEAK_MIN..=FT2_SYNC_TWEAK_MAX {
|
||||
let tw_idx = (idf - FT2_SYNC_TWEAK_MIN) as usize;
|
||||
for (n, tw) in tweak_wave[tw_idx].iter_mut().enumerate() {
|
||||
let phase = 4.0 * std::f32::consts::PI * idf as f32 * n as f32 / fs_down;
|
||||
*tw = Complex32::new(phase.cos(), phase.sin());
|
||||
}
|
||||
}
|
||||
|
||||
SyncWaveforms {
|
||||
sync_wave,
|
||||
tweak_wave,
|
||||
}
|
||||
}
|
||||
|
||||
type SyncReferenceBank = [[[Complex32; SYNC_SAMPLES]; SYNC_GROUP_COUNT]; NUM_TWEAKS];
|
||||
|
||||
fn sync_reference_bank() -> &'static SyncReferenceBank {
|
||||
static REFS: OnceLock<SyncReferenceBank> = OnceLock::new();
|
||||
|
||||
REFS.get_or_init(|| {
|
||||
let waveforms = prepare_sync_waveforms();
|
||||
let mut refs = [[[Complex32::new(0.0, 0.0); SYNC_SAMPLES]; SYNC_GROUP_COUNT]; NUM_TWEAKS];
|
||||
|
||||
for (tw_idx, refs_tw) in refs.iter_mut().enumerate() {
|
||||
for (group, refs_group) in refs_tw.iter_mut().enumerate() {
|
||||
for (i, r) in refs_group.iter_mut().enumerate() {
|
||||
*r = (waveforms.sync_wave[group][i] * waveforms.tweak_wave[tw_idx][i]).conj();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
refs
|
||||
})
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn correlate_group_fast(
|
||||
samples: &[Complex32],
|
||||
pos: usize,
|
||||
refs: &[Complex32; SYNC_SAMPLES],
|
||||
) -> f32 {
|
||||
let mut sum_re = 0.0f32;
|
||||
let mut sum_im = 0.0f32;
|
||||
|
||||
for i in 0..SYNC_SAMPLES {
|
||||
let sample = samples[pos + i * SAMPLE_STRIDE];
|
||||
let reference = refs[i];
|
||||
sum_re += sample.re * reference.re - sample.im * reference.im;
|
||||
sum_im += sample.re * reference.im + sample.im * reference.re;
|
||||
}
|
||||
|
||||
(sum_re * sum_re + sum_im * sum_im).sqrt()
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn correlate_group_clipped(
|
||||
samples: &[Complex32],
|
||||
pos: i32,
|
||||
refs: &[Complex32; SYNC_SAMPLES],
|
||||
) -> (f32, usize) {
|
||||
let mut sum_re = 0.0f32;
|
||||
let mut sum_im = 0.0f32;
|
||||
let mut usable = 0usize;
|
||||
let n_samples = samples.len() as i32;
|
||||
|
||||
for (i, &reference) in refs.iter().enumerate() {
|
||||
let sample_idx = pos + i as i32 * SAMPLE_STRIDE as i32;
|
||||
if sample_idx < 0 || sample_idx >= n_samples {
|
||||
continue;
|
||||
}
|
||||
|
||||
let sample = samples[sample_idx as usize];
|
||||
sum_re += sample.re * reference.re - sample.im * reference.im;
|
||||
sum_im += sample.re * reference.im + sample.im * reference.re;
|
||||
usable += 1;
|
||||
}
|
||||
|
||||
((sum_re * sum_re + sum_im * sum_im).sqrt(), usable)
|
||||
}
|
||||
|
||||
/// Compute the 2D sync score for a given time offset and frequency tweak.
|
||||
///
|
||||
/// Correlates the downsampled complex samples against the four Costas sync
|
||||
/// group reference waveforms, applying the specified frequency tweak.
|
||||
///
|
||||
/// `samples`: downsampled complex baseband signal.
|
||||
/// `start`: sample offset for the start of the frame.
|
||||
/// `idf`: integer frequency tweak (Hz).
|
||||
/// `waveforms`: precomputed reference waveforms.
|
||||
///
|
||||
/// Returns the sync correlation score (higher is better).
|
||||
pub fn sync2d_score(
|
||||
samples: &[Complex32],
|
||||
start: i32,
|
||||
idf: i32,
|
||||
_waveforms: &SyncWaveforms,
|
||||
) -> f32 {
|
||||
let n_samples = samples.len() as i32;
|
||||
let tw_idx = (idf - FT2_SYNC_TWEAK_MIN) as usize;
|
||||
if tw_idx >= NUM_TWEAKS {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let refs = &sync_reference_bank()[tw_idx];
|
||||
let scale = 1.0 / (2.0 * FT2_NSS as f32);
|
||||
|
||||
let mut score = 0.0f32;
|
||||
|
||||
if start >= 0 && start + FRAME_LAST_SAMPLE_OFFSET < n_samples {
|
||||
for (group, refs_group) in refs.iter().enumerate() {
|
||||
let pos = (start + group as i32 * GROUP_STRIDE) as usize;
|
||||
score += correlate_group_fast(samples, pos, refs_group) * scale;
|
||||
}
|
||||
return score;
|
||||
}
|
||||
|
||||
for (group, refs_group) in refs.iter().enumerate() {
|
||||
let pos = start + group as i32 * GROUP_STRIDE;
|
||||
if pos >= n_samples || pos + GROUP_LAST_SAMPLE_OFFSET < 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let (corr, usable) = correlate_group_clipped(samples, pos, refs_group);
|
||||
if usable > 16 {
|
||||
score += corr * scale;
|
||||
}
|
||||
}
|
||||
|
||||
score
|
||||
}
|
||||
|
||||
/// Refine frequency tweak around a coarse estimate.
|
||||
///
|
||||
/// Searches `idf` values from `center_idf - range` to `center_idf + range`
|
||||
/// and `start` values from `center_start - start_range` to
|
||||
/// `center_start + start_range`, returning the best score and parameters.
|
||||
pub fn refine_sync(
|
||||
samples: &[Complex32],
|
||||
center_start: i32,
|
||||
center_idf: i32,
|
||||
start_range: i32,
|
||||
idf_range: i32,
|
||||
waveforms: &SyncWaveforms,
|
||||
) -> (f32, i32, i32) {
|
||||
let mut best_score: f32 = -1.0;
|
||||
let mut best_start = center_start;
|
||||
let mut best_idf = center_idf;
|
||||
|
||||
for idf in (center_idf - idf_range)..=(center_idf + idf_range) {
|
||||
if !(FT2_SYNC_TWEAK_MIN..=FT2_SYNC_TWEAK_MAX).contains(&idf) {
|
||||
continue;
|
||||
}
|
||||
for start in (center_start - start_range)..=(center_start + start_range) {
|
||||
let score = sync2d_score(samples, start, idf, waveforms);
|
||||
if score > best_score {
|
||||
best_score = score;
|
||||
best_start = start;
|
||||
best_idf = idf;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(best_score, best_start, best_idf)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn waveform_preparation() {
|
||||
let wf = prepare_sync_waveforms();
|
||||
// Sync waveforms should have unit magnitude at each sample
|
||||
for group in 0..4 {
|
||||
for i in 0..64 {
|
||||
let mag = wf.sync_wave[group][i].norm();
|
||||
assert!(
|
||||
(mag - 1.0).abs() < 1e-4,
|
||||
"Sync wave group {} sample {} has magnitude {}, expected ~1.0",
|
||||
group,
|
||||
i,
|
||||
mag
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tweak_waveform_unit_magnitude() {
|
||||
let wf = prepare_sync_waveforms();
|
||||
for tw in &wf.tweak_wave {
|
||||
for &s in tw {
|
||||
let mag = s.norm();
|
||||
assert!(
|
||||
(mag - 1.0).abs() < 1e-4,
|
||||
"Tweak wave magnitude {} should be ~1.0",
|
||||
mag
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_score_zero_signal() {
|
||||
let wf = prepare_sync_waveforms();
|
||||
let samples = vec![Complex32::new(0.0, 0.0); 5000];
|
||||
let score = sync2d_score(&samples, 0, 0, &wf);
|
||||
assert!(
|
||||
score.abs() < 1e-6,
|
||||
"Score of zero signal should be ~0, got {}",
|
||||
score
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_score_out_of_range_idf() {
|
||||
let wf = prepare_sync_waveforms();
|
||||
let samples = vec![Complex32::new(1.0, 0.0); 5000];
|
||||
let score = sync2d_score(&samples, 0, FT2_SYNC_TWEAK_MAX + 100, &wf);
|
||||
assert_eq!(score, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refine_improves_on_coarse() {
|
||||
let wf = prepare_sync_waveforms();
|
||||
// Create a simple signal where the coarse and fine searches should
|
||||
// produce non-negative scores
|
||||
let samples = vec![Complex32::new(0.1, 0.05); 5000];
|
||||
let (score, _start, _idf) = refine_sync(&samples, 100, 0, 5, 4, &wf);
|
||||
assert!(score >= 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn num_tweaks_matches_range() {
|
||||
assert_eq!(
|
||||
NUM_TWEAKS,
|
||||
(FT2_SYNC_TWEAK_MAX - FT2_SYNC_TWEAK_MIN + 1) as usize
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! FT4-specific sync scoring, likelihood extraction, and tone encoding.
|
||||
|
||||
use crate::common::constants::*;
|
||||
use crate::common::crc::ftx_add_crc;
|
||||
use crate::common::decode::{get_cand_offset, wf_mag_safe, Candidate};
|
||||
use crate::common::encode::encode174;
|
||||
use crate::common::monitor::Waterfall;
|
||||
use crate::common::protocol::*;
|
||||
|
||||
/// Compute FT4 sync score for a candidate.
|
||||
pub(crate) fn ft4_sync_score(wf: &Waterfall, cand: &Candidate) -> i32 {
|
||||
let base = get_cand_offset(wf, cand);
|
||||
let mut score: i32 = 0;
|
||||
let mut num_average: i32 = 0;
|
||||
|
||||
for (m, costas_group) in FT4_COSTAS_PATTERN.iter().enumerate().take(FT4_NUM_SYNC) {
|
||||
for (k, &sm_val) in costas_group.iter().enumerate().take(FT4_LENGTH_SYNC) {
|
||||
let block = 1 + FT4_SYNC_OFFSET * m + k;
|
||||
let block_abs = cand.time_offset as i32 + block as i32;
|
||||
if block_abs < 0 {
|
||||
continue;
|
||||
}
|
||||
if block_abs >= wf.num_blocks as i32 {
|
||||
break;
|
||||
}
|
||||
|
||||
let p_offset = base + block * wf.block_stride;
|
||||
let sm = sm_val as usize;
|
||||
|
||||
if sm > 0 {
|
||||
let a = wf_mag_safe(wf, p_offset + sm).mag_int();
|
||||
let b = wf_mag_safe(wf, p_offset + sm - 1).mag_int();
|
||||
score += a - b;
|
||||
num_average += 1;
|
||||
}
|
||||
if sm < 3 {
|
||||
let a = wf_mag_safe(wf, p_offset + sm).mag_int();
|
||||
let b = wf_mag_safe(wf, p_offset + sm + 1).mag_int();
|
||||
score += a - b;
|
||||
num_average += 1;
|
||||
}
|
||||
if k > 0 && block_abs > 0 {
|
||||
let a = wf_mag_safe(wf, p_offset + sm).mag_int();
|
||||
let b_idx = (p_offset + sm).wrapping_sub(wf.block_stride);
|
||||
let b = if b_idx < wf.mag.len() {
|
||||
wf.mag[b_idx].mag_int()
|
||||
} else {
|
||||
0
|
||||
};
|
||||
score += a - b;
|
||||
num_average += 1;
|
||||
}
|
||||
if k + 1 < FT4_LENGTH_SYNC && block_abs + 1 < wf.num_blocks as i32 {
|
||||
let a = wf_mag_safe(wf, p_offset + sm).mag_int();
|
||||
let b = wf_mag_safe(wf, p_offset + sm + wf.block_stride).mag_int();
|
||||
score += a - b;
|
||||
num_average += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if num_average > 0 {
|
||||
score / num_average
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract log-likelihood ratios for FT4 symbols.
|
||||
pub(crate) fn ft4_extract_likelihood(
|
||||
wf: &Waterfall,
|
||||
cand: &Candidate,
|
||||
log174: &mut [f32; FTX_LDPC_N],
|
||||
) {
|
||||
let base = get_cand_offset(wf, cand);
|
||||
|
||||
for k in 0..FT4_ND {
|
||||
let sym_idx = k + if k < 29 {
|
||||
5
|
||||
} else if k < 58 {
|
||||
9
|
||||
} else {
|
||||
13
|
||||
};
|
||||
let bit_idx = 2 * k;
|
||||
let block = cand.time_offset as i32 + sym_idx as i32;
|
||||
|
||||
if block < 0 || block >= wf.num_blocks as i32 {
|
||||
log174[bit_idx] = 0.0;
|
||||
log174[bit_idx + 1] = 0.0;
|
||||
} else {
|
||||
let p_offset = base + sym_idx * wf.block_stride;
|
||||
ft4_extract_symbol(wf, p_offset, &mut log174[bit_idx..bit_idx + 2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ft4_extract_symbol(wf: &Waterfall, offset: usize, logl: &mut [f32]) {
|
||||
let mut s2 = [0.0f32; 4];
|
||||
for j in 0..4 {
|
||||
s2[j] = wf_mag_safe(wf, offset + FT4_GRAY_MAP[j] as usize).mag;
|
||||
}
|
||||
logl[0] = s2[2].max(s2[3]) - s2[0].max(s2[1]);
|
||||
logl[1] = s2[1].max(s2[3]) - s2[0].max(s2[2]);
|
||||
}
|
||||
|
||||
/// Generate FT4 tone sequence from payload data.
|
||||
///
|
||||
/// `payload` is a 10-byte array containing 77 bits of payload data.
|
||||
/// `tones` is an array of `FT4_NN` (105) bytes to store the generated tones (encoded as 0..3).
|
||||
///
|
||||
/// The payload is XOR'd with `FT4_XOR_SEQUENCE` before CRC computation to avoid
|
||||
/// transmitting long runs of zeros when sending CQ messages.
|
||||
///
|
||||
/// Message structure: R S4_1 D29 S4_2 D29 S4_3 D29 S4_4 R
|
||||
pub fn ft4_encode(payload: &[u8], tones: &mut [u8]) {
|
||||
let mut payload_xor = [0u8; 10];
|
||||
|
||||
// XOR payload with pseudorandom sequence
|
||||
for i in 0..10 {
|
||||
payload_xor[i] = payload[i] ^ FT4_XOR_SEQUENCE[i];
|
||||
}
|
||||
|
||||
let mut a91 = [0u8; FTX_LDPC_K_BYTES];
|
||||
|
||||
// Compute and add CRC at the end of the message
|
||||
ftx_add_crc(&payload_xor, &mut a91);
|
||||
|
||||
let mut codeword = [0u8; FTX_LDPC_N_BYTES];
|
||||
encode174(&a91, &mut codeword);
|
||||
|
||||
let mut mask: u8 = 0x80;
|
||||
let mut i_byte: usize = 0;
|
||||
|
||||
for i_tone in 0..FT4_NN {
|
||||
if i_tone == 0 || i_tone == 104 {
|
||||
tones[i_tone] = 0; // R (ramp) symbol
|
||||
} else if (1..5).contains(&i_tone) {
|
||||
tones[i_tone] = FT4_COSTAS_PATTERN[0][i_tone - 1];
|
||||
} else if (34..38).contains(&i_tone) {
|
||||
tones[i_tone] = FT4_COSTAS_PATTERN[1][i_tone - 34];
|
||||
} else if (67..71).contains(&i_tone) {
|
||||
tones[i_tone] = FT4_COSTAS_PATTERN[2][i_tone - 67];
|
||||
} else if (100..104).contains(&i_tone) {
|
||||
tones[i_tone] = FT4_COSTAS_PATTERN[3][i_tone - 100];
|
||||
} else {
|
||||
// Extract 2 bits from codeword
|
||||
let mut bits2: u8 = 0;
|
||||
|
||||
if codeword[i_byte] & mask != 0 {
|
||||
bits2 |= 2;
|
||||
}
|
||||
mask >>= 1;
|
||||
if mask == 0 {
|
||||
mask = 0x80;
|
||||
i_byte += 1;
|
||||
}
|
||||
|
||||
if codeword[i_byte] & mask != 0 {
|
||||
bits2 |= 1;
|
||||
}
|
||||
mask >>= 1;
|
||||
if mask == 0 {
|
||||
mask = 0x80;
|
||||
i_byte += 1;
|
||||
}
|
||||
|
||||
tones[i_tone] = FT4_GRAY_MAP[bits2 as usize];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ft4_encode_length() {
|
||||
let payload = [0u8; 10];
|
||||
let mut tones = [0u8; FT4_NN];
|
||||
ft4_encode(&payload, &mut tones);
|
||||
assert_eq!(tones.len(), 105);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ft4_encode_ramp_symbols() {
|
||||
let payload = [0u8; 10];
|
||||
let mut tones = [0u8; FT4_NN];
|
||||
ft4_encode(&payload, &mut tones);
|
||||
|
||||
assert_eq!(tones[0], 0, "First ramp symbol should be 0");
|
||||
assert_eq!(tones[104], 0, "Last ramp symbol should be 0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ft4_encode_costas_sync() {
|
||||
let payload = [0u8; 10];
|
||||
let mut tones = [0u8; FT4_NN];
|
||||
ft4_encode(&payload, &mut tones);
|
||||
|
||||
// Verify four Costas sync groups
|
||||
for i in 0..4 {
|
||||
assert_eq!(tones[1 + i], FT4_COSTAS_PATTERN[0][i], "S4_1 at {i}");
|
||||
}
|
||||
for i in 0..4 {
|
||||
assert_eq!(tones[34 + i], FT4_COSTAS_PATTERN[1][i], "S4_2 at {i}");
|
||||
}
|
||||
for i in 0..4 {
|
||||
assert_eq!(tones[67 + i], FT4_COSTAS_PATTERN[2][i], "S4_3 at {i}");
|
||||
}
|
||||
for i in 0..4 {
|
||||
assert_eq!(tones[100 + i], FT4_COSTAS_PATTERN[3][i], "S4_4 at {i}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ft4_encode_tones_in_range() {
|
||||
let payload = [0xAB, 0xCD, 0xEF, 0x01, 0x23, 0x45, 0x67, 0x89, 0x0A, 0xB0];
|
||||
let mut tones = [0u8; FT4_NN];
|
||||
ft4_encode(&payload, &mut tones);
|
||||
|
||||
for (i, &t) in tones.iter().enumerate() {
|
||||
assert!(t < 4, "FT4 tone at position {i} out of range: {t}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ft4_encode_deterministic() {
|
||||
let payload = [0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xBA, 0xBE, 0x00, 0x10];
|
||||
let mut tones1 = [0u8; FT4_NN];
|
||||
let mut tones2 = [0u8; FT4_NN];
|
||||
ft4_encode(&payload, &mut tones1);
|
||||
ft4_encode(&payload, &mut tones2);
|
||||
assert_eq!(tones1, tones2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! FT8-specific sync scoring, likelihood extraction, and tone encoding.
|
||||
|
||||
use crate::common::constants::*;
|
||||
use crate::common::crc::ftx_add_crc;
|
||||
use crate::common::decode::{get_cand_offset, wf_mag_safe, Candidate};
|
||||
use crate::common::encode::encode174;
|
||||
use crate::common::monitor::Waterfall;
|
||||
use crate::common::protocol::*;
|
||||
|
||||
/// Compute FT8 sync score for a candidate.
|
||||
pub(crate) fn ft8_sync_score(wf: &Waterfall, cand: &Candidate) -> i32 {
|
||||
let base = get_cand_offset(wf, cand);
|
||||
let mut score: i32 = 0;
|
||||
let mut num_average: i32 = 0;
|
||||
|
||||
for m in 0..FT8_NUM_SYNC {
|
||||
for (k, &sm_val) in FT8_COSTAS_PATTERN.iter().enumerate().take(FT8_LENGTH_SYNC) {
|
||||
let block = FT8_SYNC_OFFSET * m + k;
|
||||
let block_abs = cand.time_offset as i32 + block as i32;
|
||||
if block_abs < 0 {
|
||||
continue;
|
||||
}
|
||||
if block_abs >= wf.num_blocks as i32 {
|
||||
break;
|
||||
}
|
||||
|
||||
let p_offset = base + block * wf.block_stride;
|
||||
let sm = sm_val as usize;
|
||||
|
||||
if sm > 0 {
|
||||
let a = wf_mag_safe(wf, p_offset + sm).mag_int();
|
||||
let b = wf_mag_safe(wf, p_offset + sm - 1).mag_int();
|
||||
score += a - b;
|
||||
num_average += 1;
|
||||
}
|
||||
if sm < 7 {
|
||||
let a = wf_mag_safe(wf, p_offset + sm).mag_int();
|
||||
let b = wf_mag_safe(wf, p_offset + sm + 1).mag_int();
|
||||
score += a - b;
|
||||
num_average += 1;
|
||||
}
|
||||
if k > 0 && block_abs > 0 {
|
||||
let a = wf_mag_safe(wf, p_offset + sm).mag_int();
|
||||
let b_idx = (p_offset + sm).wrapping_sub(wf.block_stride);
|
||||
let b = if b_idx < wf.mag.len() {
|
||||
wf.mag[b_idx].mag_int()
|
||||
} else {
|
||||
0
|
||||
};
|
||||
score += a - b;
|
||||
num_average += 1;
|
||||
}
|
||||
if k + 1 < FT8_LENGTH_SYNC && block_abs + 1 < wf.num_blocks as i32 {
|
||||
let a = wf_mag_safe(wf, p_offset + sm).mag_int();
|
||||
let b = wf_mag_safe(wf, p_offset + sm + wf.block_stride).mag_int();
|
||||
score += a - b;
|
||||
num_average += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if num_average > 0 {
|
||||
score / num_average
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract log-likelihood ratios for FT8 symbols.
|
||||
pub(crate) fn ft8_extract_likelihood(
|
||||
wf: &Waterfall,
|
||||
cand: &Candidate,
|
||||
log174: &mut [f32; FTX_LDPC_N],
|
||||
) {
|
||||
let base = get_cand_offset(wf, cand);
|
||||
|
||||
for k in 0..FT8_ND {
|
||||
let sym_idx = k + if k < 29 { 7 } else { 14 };
|
||||
let bit_idx = 3 * k;
|
||||
let block = cand.time_offset as i32 + sym_idx as i32;
|
||||
|
||||
if block < 0 || block >= wf.num_blocks as i32 {
|
||||
log174[bit_idx] = 0.0;
|
||||
log174[bit_idx + 1] = 0.0;
|
||||
log174[bit_idx + 2] = 0.0;
|
||||
} else {
|
||||
let p_offset = base + sym_idx * wf.block_stride;
|
||||
ft8_extract_symbol(wf, p_offset, &mut log174[bit_idx..bit_idx + 3]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ft8_extract_symbol(wf: &Waterfall, offset: usize, logl: &mut [f32]) {
|
||||
let mut s2 = [0.0f32; 8];
|
||||
for j in 0..8 {
|
||||
s2[j] = wf_mag_safe(wf, offset + FT8_GRAY_MAP[j] as usize).mag;
|
||||
}
|
||||
logl[0] = max4(s2[4], s2[5], s2[6], s2[7]) - max4(s2[0], s2[1], s2[2], s2[3]);
|
||||
logl[1] = max4(s2[2], s2[3], s2[6], s2[7]) - max4(s2[0], s2[1], s2[4], s2[5]);
|
||||
logl[2] = max4(s2[1], s2[3], s2[5], s2[7]) - max4(s2[0], s2[2], s2[4], s2[6]);
|
||||
}
|
||||
|
||||
fn max4(a: f32, b: f32, c: f32, d: f32) -> f32 {
|
||||
a.max(b).max(c.max(d))
|
||||
}
|
||||
|
||||
/// Generate FT8 tone sequence from payload data.
|
||||
///
|
||||
/// `payload` is a 10-byte array containing 77 bits of payload data.
|
||||
/// `tones` is an array of `FT8_NN` (79) bytes to store the generated tones (encoded as 0..7).
|
||||
///
|
||||
/// Message structure: S7 D29 S7 D29 S7
|
||||
pub fn ft8_encode(payload: &[u8], tones: &mut [u8]) {
|
||||
let mut a91 = [0u8; FTX_LDPC_K_BYTES];
|
||||
|
||||
// Compute and add CRC at the end of the message
|
||||
ftx_add_crc(payload, &mut a91);
|
||||
|
||||
let mut codeword = [0u8; FTX_LDPC_N_BYTES];
|
||||
encode174(&a91, &mut codeword);
|
||||
|
||||
let mut mask: u8 = 0x80;
|
||||
let mut i_byte: usize = 0;
|
||||
|
||||
for i_tone in 0..FT8_NN {
|
||||
if i_tone < 7 {
|
||||
tones[i_tone] = FT8_COSTAS_PATTERN[i_tone];
|
||||
} else if (36..43).contains(&i_tone) {
|
||||
tones[i_tone] = FT8_COSTAS_PATTERN[i_tone - 36];
|
||||
} else if (72..79).contains(&i_tone) {
|
||||
tones[i_tone] = FT8_COSTAS_PATTERN[i_tone - 72];
|
||||
} else {
|
||||
// Extract 3 bits from codeword
|
||||
let mut bits3: u8 = 0;
|
||||
|
||||
if codeword[i_byte] & mask != 0 {
|
||||
bits3 |= 4;
|
||||
}
|
||||
mask >>= 1;
|
||||
if mask == 0 {
|
||||
mask = 0x80;
|
||||
i_byte += 1;
|
||||
}
|
||||
|
||||
if codeword[i_byte] & mask != 0 {
|
||||
bits3 |= 2;
|
||||
}
|
||||
mask >>= 1;
|
||||
if mask == 0 {
|
||||
mask = 0x80;
|
||||
i_byte += 1;
|
||||
}
|
||||
|
||||
if codeword[i_byte] & mask != 0 {
|
||||
bits3 |= 1;
|
||||
}
|
||||
mask >>= 1;
|
||||
if mask == 0 {
|
||||
mask = 0x80;
|
||||
i_byte += 1;
|
||||
}
|
||||
|
||||
tones[i_tone] = FT8_GRAY_MAP[bits3 as usize];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ft8_encode_length() {
|
||||
let payload = [0u8; 10];
|
||||
let mut tones = [0u8; FT8_NN];
|
||||
ft8_encode(&payload, &mut tones);
|
||||
assert_eq!(tones.len(), 79);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ft8_encode_costas_sync() {
|
||||
let payload = [0u8; 10];
|
||||
let mut tones = [0u8; FT8_NN];
|
||||
ft8_encode(&payload, &mut tones);
|
||||
|
||||
// Verify the three Costas sync patterns at positions 0..7, 36..43, 72..79
|
||||
for i in 0..7 {
|
||||
assert_eq!(tones[i], FT8_COSTAS_PATTERN[i], "Costas S1 mismatch at {i}");
|
||||
assert_eq!(
|
||||
tones[36 + i],
|
||||
FT8_COSTAS_PATTERN[i],
|
||||
"Costas S2 mismatch at {}",
|
||||
36 + i
|
||||
);
|
||||
assert_eq!(
|
||||
tones[72 + i],
|
||||
FT8_COSTAS_PATTERN[i],
|
||||
"Costas S3 mismatch at {}",
|
||||
72 + i
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ft8_encode_tones_in_range() {
|
||||
let payload = [0xAB, 0xCD, 0xEF, 0x01, 0x23, 0x45, 0x67, 0x89, 0x0A, 0xB0];
|
||||
let mut tones = [0u8; FT8_NN];
|
||||
ft8_encode(&payload, &mut tones);
|
||||
|
||||
for (i, &t) in tones.iter().enumerate() {
|
||||
assert!(t < 8, "FT8 tone at position {i} out of range: {t}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ft8_encode_deterministic() {
|
||||
let payload = [0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xBA, 0xBE, 0x00, 0x10];
|
||||
let mut tones1 = [0u8; FT8_NN];
|
||||
let mut tones2 = [0u8; FT8_NN];
|
||||
ft8_encode(&payload, &mut tones1);
|
||||
ft8_encode(&payload, &mut tones2);
|
||||
assert_eq!(tones1, tones2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ft8_encode_different_payloads_differ() {
|
||||
let payload1 = [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
|
||||
let payload2 = [0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0];
|
||||
let mut tones1 = [0u8; FT8_NN];
|
||||
let mut tones2 = [0u8; FT8_NN];
|
||||
ft8_encode(&payload1, &mut tones1);
|
||||
ft8_encode(&payload2, &mut tones2);
|
||||
// Data tones should differ (sync tones are the same)
|
||||
assert_ne!(tones1, tones2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
pub mod common;
|
||||
mod decoder;
|
||||
#[cfg(feature = "ft2")]
|
||||
pub mod ft2;
|
||||
pub mod ft4;
|
||||
pub mod ft8;
|
||||
|
||||
pub use decoder::{Ft8DecodeResult, Ft8Decoder};
|
||||
@@ -0,0 +1,12 @@
|
||||
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
[package]
|
||||
name = "trx-rds"
|
||||
version.workspace = true
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
trx-core = { path = "../../trx-core" }
|
||||
rustfft = "6"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
||||
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
[package]
|
||||
name = "trx-vdes"
|
||||
version.workspace = true
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
num-complex = "0.4"
|
||||
trx-core = { path = "../../trx-core" }
|
||||
@@ -0,0 +1,153 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! CRC-16 for VDES link-layer frames.
|
||||
//!
|
||||
//! ITU-R M.2092-1 uses the same CRC-16-CCITT polynomial (0x1021) as AIS,
|
||||
//! applied over the decoded information bits (excluding FEC tail). The CRC
|
||||
//! is transmitted MSB-first in the encoded frame.
|
||||
|
||||
/// Pre-computed CRC-16-CCITT lookup table (normal / MSB-first form,
|
||||
/// polynomial 0x1021).
|
||||
const CRC16_CCITT_TABLE: [u16; 256] = {
|
||||
let mut table = [0u16; 256];
|
||||
let mut i = 0usize;
|
||||
while i < 256 {
|
||||
let mut crc = (i as u16) << 8;
|
||||
let mut j = 0;
|
||||
while j < 8 {
|
||||
if crc & 0x8000 != 0 {
|
||||
crc = (crc << 1) ^ 0x1021;
|
||||
} else {
|
||||
crc <<= 1;
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
table[i] = crc;
|
||||
i += 1;
|
||||
}
|
||||
table
|
||||
};
|
||||
|
||||
/// Compute CRC-16-CCITT over a byte slice (MSB-first, init 0xFFFF).
|
||||
pub fn crc16_ccitt(data: &[u8]) -> u16 {
|
||||
let mut crc: u16 = 0xFFFF;
|
||||
for &b in data {
|
||||
crc = (crc << 8) ^ CRC16_CCITT_TABLE[((crc >> 8) ^ b as u16) as usize];
|
||||
}
|
||||
crc ^ 0xFFFF
|
||||
}
|
||||
|
||||
/// Compute CRC-16-CCITT over a bit slice (MSB-first packing).
|
||||
///
|
||||
/// Packs the bit slice into bytes (zero-padding the last byte if needed),
|
||||
/// then runs the CRC over the packed data.
|
||||
pub fn crc16_ccitt_bits(bits: &[u8]) -> u16 {
|
||||
let bytes = pack_bits_to_bytes(bits);
|
||||
crc16_ccitt(&bytes)
|
||||
}
|
||||
|
||||
/// Check CRC-16-CCITT on a decoded bit-stream.
|
||||
///
|
||||
/// The last 16 bits of `bits` are the transmitted CRC. Returns `true` if
|
||||
/// the CRC computed over the preceding bits matches the received CRC.
|
||||
pub fn check_crc16(bits: &[u8]) -> bool {
|
||||
if bits.len() < 16 {
|
||||
return false;
|
||||
}
|
||||
let payload_bits = &bits[..bits.len() - 16];
|
||||
let crc_bits = &bits[bits.len() - 16..];
|
||||
|
||||
let computed = crc16_ccitt_bits(payload_bits);
|
||||
let received = bits_to_u16(crc_bits);
|
||||
|
||||
computed == received
|
||||
}
|
||||
|
||||
/// Extract the 16-bit CRC value from a bit slice.
|
||||
fn bits_to_u16(bits: &[u8]) -> u16 {
|
||||
let mut value = 0u16;
|
||||
for &bit in bits.iter().take(16) {
|
||||
value = (value << 1) | u16::from(bit & 1);
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
/// Pack a bit slice into bytes (MSB-first, zero-pad last byte).
|
||||
fn pack_bits_to_bytes(bits: &[u8]) -> Vec<u8> {
|
||||
let mut bytes = Vec::with_capacity(bits.len().div_ceil(8));
|
||||
for chunk in bits.chunks(8) {
|
||||
let mut byte = 0u8;
|
||||
for (i, &bit) in chunk.iter().enumerate() {
|
||||
byte |= (bit & 1) << (7 - i);
|
||||
}
|
||||
bytes.push(byte);
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn crc16_known_vector() {
|
||||
// CRC-16-CCITT (init=0xFFFF, poly=0x1021, xorout=0xFFFF) of "123456789"
|
||||
let data = b"123456789";
|
||||
let crc = crc16_ccitt(data);
|
||||
assert_eq!(crc, 0xD64E, "CRC-16-CCITT of '123456789'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crc16_bits_matches_bytes() {
|
||||
let data = [0xDE, 0xAD, 0xBE, 0xEF];
|
||||
let crc_bytes = crc16_ccitt(&data);
|
||||
|
||||
let bits: Vec<u8> = data
|
||||
.iter()
|
||||
.flat_map(|&b| (0..8).rev().map(move |i| (b >> i) & 1))
|
||||
.collect();
|
||||
let crc_bits = crc16_ccitt_bits(&bits);
|
||||
assert_eq!(crc_bytes, crc_bits);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_crc16_valid() {
|
||||
let payload = [0x01, 0x02, 0x03, 0x04];
|
||||
let crc = crc16_ccitt(&payload);
|
||||
let mut bits: Vec<u8> = payload
|
||||
.iter()
|
||||
.flat_map(|&b| (0..8).rev().map(move |i| (b >> i) & 1))
|
||||
.collect();
|
||||
for i in (0..16).rev() {
|
||||
bits.push(((crc >> i) & 1) as u8);
|
||||
}
|
||||
assert!(check_crc16(&bits));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_crc16_invalid() {
|
||||
let payload = [0x01, 0x02, 0x03, 0x04];
|
||||
let mut bits: Vec<u8> = payload
|
||||
.iter()
|
||||
.flat_map(|&b| (0..8).rev().map(move |i| (b >> i) & 1))
|
||||
.collect();
|
||||
// Append wrong CRC
|
||||
for _ in 0..16 {
|
||||
bits.push(0);
|
||||
}
|
||||
assert!(!check_crc16(&bits));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pack_bits_round_trips() {
|
||||
let original = [0xAB, 0xCD];
|
||||
let bits: Vec<u8> = original
|
||||
.iter()
|
||||
.flat_map(|&b| (0..8).rev().map(move |i| (b >> i) & 1))
|
||||
.collect();
|
||||
let packed = pack_bits_to_bytes(&bits);
|
||||
assert_eq!(packed, original);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,450 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! VDES link-layer frame parsing per ITU-R M.2092-1.
|
||||
//!
|
||||
//! After FEC decoding and CRC validation, the decoded information bits
|
||||
//! contain a link-layer frame with the following structure:
|
||||
//!
|
||||
//! ```text
|
||||
//! ┌────────┬──────────┬──────────┬──────────┬─────────┬─────────┐
|
||||
//! │ MsgID │ Repeat │ SessionID│ SourceID │ Payload │ CRC-16 │
|
||||
//! │ 4 bits │ 2 bits │ 6 bits │ 32 bits │ variable│ 16 bits │
|
||||
//! └────────┴──────────┴──────────┴──────────┴─────────┴─────────┘
|
||||
//! ```
|
||||
//!
|
||||
//! This module provides structured parsing of the link-layer header and
|
||||
//! payload fields for each VDES message type (0–6), including:
|
||||
//! - Station addressing (source/destination MMSIs)
|
||||
//! - ASM (Application Specific Message) identification
|
||||
//! - Geographic bounding box parsing (Message 6)
|
||||
//! - ACK/NACK channel quality reporting (Message 5)
|
||||
|
||||
use crate::crc;
|
||||
|
||||
/// Parsed link-layer frame result.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LinkLayerFrame {
|
||||
/// Message type ID (0–6).
|
||||
pub message_id: u8,
|
||||
/// Repeat indicator (0–3).
|
||||
pub repeat: u8,
|
||||
/// Session ID (0–63).
|
||||
pub session_id: u8,
|
||||
/// Source station ID (MMSI-like, 32 bits).
|
||||
pub source_id: u32,
|
||||
/// Destination station ID for addressed messages.
|
||||
pub destination_id: Option<u32>,
|
||||
/// Data bit count from the header.
|
||||
pub data_count: Option<u16>,
|
||||
/// ASM (Application Specific Message) identifier.
|
||||
pub asm_identifier: Option<u16>,
|
||||
/// ACK/NACK bitmask (Message 5).
|
||||
pub ack_nack_mask: Option<u16>,
|
||||
/// Channel quality indicator (Message 5).
|
||||
pub channel_quality: Option<u8>,
|
||||
/// Geographic bounding box: (sw_lat, sw_lon, ne_lat, ne_lon) in degrees.
|
||||
pub geo_box: Option<GeoBox>,
|
||||
/// Application payload bits (after header, before CRC).
|
||||
pub payload_bits: Vec<u8>,
|
||||
/// Whether the CRC-16 validated successfully.
|
||||
pub crc_ok: bool,
|
||||
/// Human-readable message type label.
|
||||
pub label: &'static str,
|
||||
}
|
||||
|
||||
/// Geographic bounding box for Message 6.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GeoBox {
|
||||
pub ne_lat: f64,
|
||||
pub ne_lon: f64,
|
||||
pub sw_lat: f64,
|
||||
pub sw_lon: f64,
|
||||
}
|
||||
|
||||
impl GeoBox {
|
||||
/// Center latitude of the bounding box.
|
||||
pub fn center_lat(&self) -> f64 {
|
||||
(self.ne_lat + self.sw_lat) * 0.5
|
||||
}
|
||||
/// Center longitude of the bounding box.
|
||||
pub fn center_lon(&self) -> f64 {
|
||||
(self.ne_lon + self.sw_lon) * 0.5
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimum bit length for a valid link-layer frame (header + CRC).
|
||||
const MIN_FRAME_BITS: usize = 4 + 2 + 6 + 32 + 16; // 60 bits
|
||||
|
||||
/// Parse a decoded bit stream into a link-layer frame.
|
||||
///
|
||||
/// `bits` should be the FEC-decoded information bits including the trailing
|
||||
/// 16-bit CRC. Returns `None` if the frame is too short or the message ID
|
||||
/// is invalid.
|
||||
pub fn parse_link_layer(bits: &[u8]) -> Option<LinkLayerFrame> {
|
||||
if bits.len() < MIN_FRAME_BITS {
|
||||
return None;
|
||||
}
|
||||
|
||||
let crc_ok = crc::check_crc16(bits);
|
||||
|
||||
// Strip CRC for payload parsing
|
||||
let data_bits = &bits[..bits.len() - 16];
|
||||
|
||||
let message_id = read_bits_u8(data_bits, 0, 4)?;
|
||||
if message_id > 6 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let repeat = read_bits_u8(data_bits, 4, 2).unwrap_or(0);
|
||||
let session_id = read_bits_u8(data_bits, 6, 6).unwrap_or(0);
|
||||
let source_id = read_bits_u32(data_bits, 12, 32).unwrap_or(0);
|
||||
|
||||
let mut frame = LinkLayerFrame {
|
||||
message_id,
|
||||
repeat,
|
||||
session_id,
|
||||
source_id,
|
||||
destination_id: None,
|
||||
data_count: None,
|
||||
asm_identifier: None,
|
||||
ack_nack_mask: None,
|
||||
channel_quality: None,
|
||||
geo_box: None,
|
||||
payload_bits: Vec::new(),
|
||||
crc_ok,
|
||||
label: message_label(message_id),
|
||||
};
|
||||
|
||||
match message_id {
|
||||
0 => parse_msg0(data_bits, &mut frame),
|
||||
1 => parse_msg1(data_bits, &mut frame),
|
||||
2 => parse_msg2(data_bits, &mut frame),
|
||||
3 => parse_msg3(data_bits, &mut frame),
|
||||
4 => parse_msg4(data_bits, &mut frame),
|
||||
5 => parse_msg5(data_bits, &mut frame),
|
||||
6 => parse_msg6(data_bits, &mut frame),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Some(frame)
|
||||
}
|
||||
|
||||
/// Message 0: Broadcast (unaddressed data)
|
||||
///
|
||||
/// ```text
|
||||
/// ┌──────┬────────┬─────────┬──────────┬───────────┬─────────┐
|
||||
/// │MsgID │Repeat │SessionID│SourceID │ DataCount │ Payload │
|
||||
/// │4 │2 │6 │32 │ 11 │variable │
|
||||
/// └──────┴────────┴─────────┴──────────┴───────────┴─────────┘
|
||||
/// ```
|
||||
fn parse_msg0(bits: &[u8], frame: &mut LinkLayerFrame) {
|
||||
frame.data_count = read_bits_u16(bits, 44, 11);
|
||||
let start = 55;
|
||||
frame.payload_bits = extract_payload(bits, start, frame.data_count);
|
||||
}
|
||||
|
||||
/// Message 1: Scheduled (standard TDMA)
|
||||
///
|
||||
/// ```text
|
||||
/// ┌──────┬────────┬─────────┬──────────┬───────────┬────────────┬─────────┐
|
||||
/// │MsgID │Repeat │SessionID│SourceID │ DataCount │ ASM Ident │ Payload │
|
||||
/// │4 │2 │6 │32 │ 11 │ 16 │variable │
|
||||
/// └──────┴────────┴─────────┴──────────┴───────────┴────────────┴─────────┘
|
||||
/// ```
|
||||
fn parse_msg1(bits: &[u8], frame: &mut LinkLayerFrame) {
|
||||
frame.data_count = read_bits_u16(bits, 44, 11);
|
||||
frame.asm_identifier = read_bits_u16(bits, 55, 16);
|
||||
let start = 71;
|
||||
frame.payload_bits = extract_payload(bits, start, frame.data_count);
|
||||
}
|
||||
|
||||
/// Message 2: Scheduled (ITDMA)
|
||||
fn parse_msg2(bits: &[u8], frame: &mut LinkLayerFrame) {
|
||||
frame.data_count = read_bits_u16(bits, 44, 11);
|
||||
frame.asm_identifier = read_bits_u16(bits, 55, 16);
|
||||
let start = 71;
|
||||
frame.payload_bits = extract_payload(bits, start, frame.data_count);
|
||||
}
|
||||
|
||||
/// Message 3: Addressed (standard TDMA)
|
||||
///
|
||||
/// ```text
|
||||
/// ┌──────┬────────┬─────────┬──────────┬─────────────┬───────────┬────────────┬─────────┐
|
||||
/// │MsgID │Repeat │SessionID│ SourceID │DestinationID│ DataCount │ ASM Ident │ Payload │
|
||||
/// │4 │2 │6 │32 │32 │ 11 │ 16 │variable │
|
||||
/// └──────┴────────┴─────────┴──────────┴─────────────┴───────────┴────────────┴─────────┘
|
||||
/// ```
|
||||
fn parse_msg3(bits: &[u8], frame: &mut LinkLayerFrame) {
|
||||
frame.destination_id = read_bits_u32(bits, 44, 32);
|
||||
frame.data_count = read_bits_u16(bits, 76, 11);
|
||||
frame.asm_identifier = read_bits_u16(bits, 87, 16);
|
||||
let start = 103;
|
||||
frame.payload_bits = extract_payload(bits, start, frame.data_count);
|
||||
}
|
||||
|
||||
/// Message 4: Addressed (ITDMA)
|
||||
fn parse_msg4(bits: &[u8], frame: &mut LinkLayerFrame) {
|
||||
frame.destination_id = read_bits_u32(bits, 44, 32);
|
||||
frame.data_count = read_bits_u16(bits, 76, 11);
|
||||
frame.asm_identifier = read_bits_u16(bits, 87, 16);
|
||||
let start = 103;
|
||||
frame.payload_bits = extract_payload(bits, start, frame.data_count);
|
||||
}
|
||||
|
||||
/// Message 5: Acknowledge (ACK/NACK)
|
||||
///
|
||||
/// ```text
|
||||
/// ┌──────┬────────┬─────────┬──────────┬─────────────┬────────────┬─────────────┐
|
||||
/// │MsgID │Repeat │SessionID│ SourceID │DestinationID│ ACK/NACK │ ChQuality │
|
||||
/// │4 │2 │6 │32 │32 │ 16 │ 8 │
|
||||
/// └──────┴────────┴─────────┴──────────┴─────────────┴────────────┴─────────────┘
|
||||
/// ```
|
||||
fn parse_msg5(bits: &[u8], frame: &mut LinkLayerFrame) {
|
||||
frame.destination_id = read_bits_u32(bits, 44, 32);
|
||||
frame.ack_nack_mask = read_bits_u16(bits, 76, 16);
|
||||
frame.channel_quality = read_bits_u8(bits, 92, 8);
|
||||
}
|
||||
|
||||
/// Message 6: Geo-referenced data
|
||||
///
|
||||
/// ```text
|
||||
/// ┌──────┬────────┬─────────┬──────────┬────────┬────────┬────────┬────────┬───────────┬────────────┬─────────┐
|
||||
/// │MsgID │Repeat │SessionID│ SourceID │NE Lon │NE Lat │SW Lon │SW Lat │ DataCount │ ASM Ident │ Payload │
|
||||
/// │4 │2 │6 │32 │18 │17 │18 │17 │ 11 │ 16 │variable │
|
||||
/// └──────┴────────┴─────────┴──────────┴────────┴────────┴────────┴────────┴───────────┴────────────┴─────────┘
|
||||
/// ```
|
||||
fn parse_msg6(bits: &[u8], frame: &mut LinkLayerFrame) {
|
||||
let ne_lon = read_signed_bits(bits, 44, 18);
|
||||
let ne_lat = read_signed_bits(bits, 62, 17);
|
||||
let sw_lon = read_signed_bits(bits, 79, 18);
|
||||
let sw_lat = read_signed_bits(bits, 97, 17);
|
||||
|
||||
if let (Some(ne_lon), Some(ne_lat), Some(sw_lon), Some(sw_lat)) =
|
||||
(ne_lon, ne_lat, sw_lon, sw_lat)
|
||||
{
|
||||
let ne_lon_deg = ne_lon as f64 / 600.0;
|
||||
let ne_lat_deg = ne_lat as f64 / 600.0;
|
||||
let sw_lon_deg = sw_lon as f64 / 600.0;
|
||||
let sw_lat_deg = sw_lat as f64 / 600.0;
|
||||
|
||||
if valid_geo_coord(ne_lat_deg, ne_lon_deg) && valid_geo_coord(sw_lat_deg, sw_lon_deg) {
|
||||
frame.geo_box = Some(GeoBox {
|
||||
ne_lat: ne_lat_deg,
|
||||
ne_lon: ne_lon_deg,
|
||||
sw_lat: sw_lat_deg,
|
||||
sw_lon: sw_lon_deg,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
frame.data_count = read_bits_u16(bits, 114, 11);
|
||||
frame.asm_identifier = read_bits_u16(bits, 125, 16);
|
||||
let start = 141;
|
||||
frame.payload_bits = extract_payload(bits, start, frame.data_count);
|
||||
}
|
||||
|
||||
fn message_label(id: u8) -> &'static str {
|
||||
match id {
|
||||
0 => "Broadcast",
|
||||
1 => "Scheduled",
|
||||
2 => "Scheduled ITDMA",
|
||||
3 => "Addressed",
|
||||
4 => "Addressed ITDMA",
|
||||
5 => "Acknowledge",
|
||||
6 => "Geo-referenced",
|
||||
_ => "Unknown",
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_payload(bits: &[u8], start: usize, count: Option<u16>) -> Vec<u8> {
|
||||
let count = match count {
|
||||
Some(c) => c as usize,
|
||||
None => return Vec::new(),
|
||||
};
|
||||
let end = start.saturating_add(count).min(bits.len());
|
||||
if start >= end {
|
||||
return Vec::new();
|
||||
}
|
||||
bits[start..end].to_vec()
|
||||
}
|
||||
|
||||
fn valid_geo_coord(lat: f64, lon: f64) -> bool {
|
||||
(-90.0..=90.0).contains(&lat) && (-180.0..=180.0).contains(&lon)
|
||||
}
|
||||
|
||||
fn read_bits_u8(bits: &[u8], start: usize, len: usize) -> Option<u8> {
|
||||
read_bits_u32(bits, start, len).and_then(|v| u8::try_from(v).ok())
|
||||
}
|
||||
|
||||
fn read_bits_u16(bits: &[u8], start: usize, len: usize) -> Option<u16> {
|
||||
read_bits_u32(bits, start, len).and_then(|v| u16::try_from(v).ok())
|
||||
}
|
||||
|
||||
fn read_bits_u32(bits: &[u8], start: usize, len: usize) -> Option<u32> {
|
||||
if len == 0 || len > 32 {
|
||||
return None;
|
||||
}
|
||||
let end = start.checked_add(len)?;
|
||||
let slice = bits.get(start..end)?;
|
||||
let mut value = 0u32;
|
||||
for &bit in slice {
|
||||
value = (value << 1) | u32::from(bit & 1);
|
||||
}
|
||||
Some(value)
|
||||
}
|
||||
|
||||
fn read_signed_bits(bits: &[u8], start: usize, len: usize) -> Option<i32> {
|
||||
let raw = read_bits_u32(bits, start, len)?;
|
||||
if len == 0 || len > 31 {
|
||||
return None;
|
||||
}
|
||||
let sign_mask = 1u32 << (len - 1);
|
||||
if raw & sign_mask == 0 {
|
||||
Some(raw as i32)
|
||||
} else {
|
||||
let extended = raw | (!0u32 << len);
|
||||
Some(extended as i32)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::crc;
|
||||
|
||||
fn write_bits(bits: &mut [u8], start: usize, len: usize, value: u32) {
|
||||
for idx in 0..len {
|
||||
let shift = len - idx - 1;
|
||||
bits[start + idx] = ((value >> shift) & 1) as u8;
|
||||
}
|
||||
}
|
||||
|
||||
fn write_signed_bits(bits: &mut [u8], start: usize, len: usize, value: i32) {
|
||||
let mask = if len >= 32 {
|
||||
u32::MAX
|
||||
} else {
|
||||
(1u32 << len) - 1
|
||||
};
|
||||
write_bits(bits, start, len, (value as u32) & mask);
|
||||
}
|
||||
|
||||
fn append_crc(bits: &mut Vec<u8>) {
|
||||
let crc = crc::crc16_ccitt_bits(&bits[..]);
|
||||
for i in (0..16).rev() {
|
||||
bits.push(((crc >> i) & 1) as u8);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_msg0_broadcast() {
|
||||
let mut bits = vec![0u8; 100];
|
||||
write_bits(&mut bits, 0, 4, 0); // message_id = 0
|
||||
write_bits(&mut bits, 4, 2, 1); // repeat = 1
|
||||
write_bits(&mut bits, 6, 6, 5); // session_id = 5
|
||||
write_bits(&mut bits, 12, 32, 123456); // source_id
|
||||
write_bits(&mut bits, 44, 11, 20); // data_count = 20
|
||||
// Fill some payload
|
||||
for i in 55..75 {
|
||||
bits[i] = (i % 2) as u8;
|
||||
}
|
||||
append_crc(&mut bits);
|
||||
|
||||
let frame = parse_link_layer(&bits).expect("should parse");
|
||||
assert_eq!(frame.message_id, 0);
|
||||
assert_eq!(frame.repeat, 1);
|
||||
assert_eq!(frame.session_id, 5);
|
||||
assert_eq!(frame.source_id, 123456);
|
||||
assert_eq!(frame.data_count, Some(20));
|
||||
assert_eq!(frame.payload_bits.len(), 20);
|
||||
assert!(frame.crc_ok);
|
||||
assert_eq!(frame.label, "Broadcast");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_msg3_addressed() {
|
||||
let mut bits = vec![0u8; 150];
|
||||
write_bits(&mut bits, 0, 4, 3); // message_id = 3
|
||||
write_bits(&mut bits, 4, 2, 0); // repeat
|
||||
write_bits(&mut bits, 6, 6, 10); // session_id
|
||||
write_bits(&mut bits, 12, 32, 111111); // source_id
|
||||
write_bits(&mut bits, 44, 32, 222222); // destination_id
|
||||
write_bits(&mut bits, 76, 11, 15); // data_count
|
||||
write_bits(&mut bits, 87, 16, 0x1234); // asm_identifier
|
||||
append_crc(&mut bits);
|
||||
|
||||
let frame = parse_link_layer(&bits).expect("should parse");
|
||||
assert_eq!(frame.message_id, 3);
|
||||
assert_eq!(frame.source_id, 111111);
|
||||
assert_eq!(frame.destination_id, Some(222222));
|
||||
assert_eq!(frame.asm_identifier, Some(0x1234));
|
||||
assert!(frame.crc_ok);
|
||||
assert_eq!(frame.label, "Addressed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_msg5_acknowledge() {
|
||||
let mut bits = vec![0u8; 120];
|
||||
write_bits(&mut bits, 0, 4, 5); // message_id = 5
|
||||
write_bits(&mut bits, 4, 2, 0);
|
||||
write_bits(&mut bits, 6, 6, 0);
|
||||
write_bits(&mut bits, 12, 32, 999999);
|
||||
write_bits(&mut bits, 44, 32, 888888);
|
||||
write_bits(&mut bits, 76, 16, 0xABCD); // ack_nack
|
||||
write_bits(&mut bits, 92, 8, 42); // channel_quality
|
||||
append_crc(&mut bits);
|
||||
|
||||
let frame = parse_link_layer(&bits).expect("should parse");
|
||||
assert_eq!(frame.message_id, 5);
|
||||
assert_eq!(frame.ack_nack_mask, Some(0xABCD));
|
||||
assert_eq!(frame.channel_quality, Some(42));
|
||||
assert!(frame.crc_ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_msg6_geo_box() {
|
||||
let mut bits = vec![0u8; 200];
|
||||
write_bits(&mut bits, 0, 4, 6);
|
||||
write_bits(&mut bits, 4, 2, 0);
|
||||
write_bits(&mut bits, 6, 6, 0);
|
||||
write_bits(&mut bits, 12, 32, 54321);
|
||||
// NE corner: lon=10.0°, lat=20.0°
|
||||
write_signed_bits(&mut bits, 44, 18, (10.0_f64 * 600.0) as i32);
|
||||
write_signed_bits(&mut bits, 62, 17, (20.0_f64 * 600.0) as i32);
|
||||
// SW corner: lon=-5.0°, lat=15.0°
|
||||
write_signed_bits(&mut bits, 79, 18, (-5.0_f64 * 600.0) as i32);
|
||||
write_signed_bits(&mut bits, 97, 17, (15.0_f64 * 600.0) as i32);
|
||||
write_bits(&mut bits, 114, 11, 10); // data_count
|
||||
write_bits(&mut bits, 125, 16, 0x5678); // asm_identifier
|
||||
append_crc(&mut bits);
|
||||
|
||||
let frame = parse_link_layer(&bits).expect("should parse");
|
||||
assert_eq!(frame.message_id, 6);
|
||||
let geo = frame.geo_box.expect("geo_box should be present");
|
||||
assert!((geo.ne_lon - 10.0).abs() < 0.01);
|
||||
assert!((geo.ne_lat - 20.0).abs() < 0.01);
|
||||
assert!((geo.sw_lon - (-5.0)).abs() < 0.01);
|
||||
assert!((geo.sw_lat - 15.0).abs() < 0.01);
|
||||
assert!(frame.crc_ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_crc_detected() {
|
||||
let mut bits = vec![0u8; 80];
|
||||
write_bits(&mut bits, 0, 4, 0);
|
||||
write_bits(&mut bits, 12, 32, 1);
|
||||
write_bits(&mut bits, 44, 11, 0);
|
||||
// Append wrong CRC
|
||||
bits.extend_from_slice(&[0; 16]);
|
||||
|
||||
let frame = parse_link_layer(&bits).expect("should parse");
|
||||
assert!(!frame.crc_ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn too_short_returns_none() {
|
||||
let bits = vec![0u8; 10];
|
||||
assert!(parse_link_layer(&bits).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,571 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! Turbo FEC decoder for VDES TER-MCS-1 (100 kHz channel).
|
||||
//!
|
||||
//! ITU-R M.2092-1 specifies a turbo code consisting of two 8-state Recursive
|
||||
//! Systematic Convolutional (RSC) encoders with feedback polynomial 013 (octal)
|
||||
//! and feedforward polynomial 015 (octal), connected through a Quadratic
|
||||
//! Permutation Polynomial (QPP) interleaver.
|
||||
//!
|
||||
//! The encoder produces systematic bits plus two parity streams which are
|
||||
//! punctured to achieve rate 1/2. This module implements:
|
||||
//!
|
||||
//! - QPP interleaver generation
|
||||
//! - BCJR (MAP) component decoder with log-domain arithmetic
|
||||
//! - Iterative turbo decoding with configurable iteration count
|
||||
//! - Puncture pattern handling for rate 1/2
|
||||
|
||||
/// Number of turbo decoder iterations.
|
||||
const TURBO_ITERATIONS: usize = 8;
|
||||
|
||||
/// RSC constraint length K=4 → 8 states.
|
||||
const NUM_STATES: usize = 8;
|
||||
|
||||
/// Tail bits per constituent encoder (K-1 = 3).
|
||||
const TAIL_BITS: usize = 3;
|
||||
|
||||
/// RSC feedback polynomial (octal 013 = binary 001011 → decimal 11).
|
||||
/// g_fb(D) = 1 + D + D^3
|
||||
const FB_POLY: u8 = 0o13; // 0b001_011
|
||||
|
||||
/// RSC feedforward polynomial (octal 015 = binary 001101 → decimal 13).
|
||||
/// g_ff(D) = 1 + D^2 + D^3
|
||||
const FF_POLY: u8 = 0o15; // 0b001_101
|
||||
|
||||
/// Log-likelihood ratio type (soft bit representation).
|
||||
type Llr = f32;
|
||||
|
||||
/// Large magnitude used as "infinity" in log-domain computations.
|
||||
const LLR_INF: Llr = 1.0e6;
|
||||
|
||||
/// QPP interleaver: π(i) = (f1 * i + f2 * i^2) mod K
|
||||
///
|
||||
/// ITU-R M.2092-1 Table A2-5 defines QPP parameters for various block sizes.
|
||||
/// This function returns the interleaver permutation vector for a given
|
||||
/// information block size.
|
||||
pub fn qpp_interleaver(block_size: usize) -> Vec<usize> {
|
||||
let (f1, f2) = qpp_parameters(block_size);
|
||||
let mut perm = Vec::with_capacity(block_size);
|
||||
for i in 0..block_size {
|
||||
let idx = ((f1 as u64 * i as u64 + f2 as u64 * (i as u64 * i as u64)) % block_size as u64)
|
||||
as usize;
|
||||
perm.push(idx);
|
||||
}
|
||||
perm
|
||||
}
|
||||
|
||||
/// QPP parameter lookup for VDE-TER block sizes.
|
||||
///
|
||||
/// Parameters (f1, f2) are chosen per ITU-R M.2092-1 Table A2-5 so that
|
||||
/// the permutation polynomial generates a valid interleaver (all indices
|
||||
/// are unique). For block sizes not in the table, we use a best-effort
|
||||
/// selection.
|
||||
fn qpp_parameters(block_size: usize) -> (usize, usize) {
|
||||
match block_size {
|
||||
// TER-MCS-1.100: 936 info bits (1872 coded / 2 = 936)
|
||||
936 => (11, 156),
|
||||
// TER-MCS-1.50: 468 info bits
|
||||
468 => (11, 156),
|
||||
// TER-MCS-2.100: higher MCS, 1872 info bits
|
||||
1872 => (11, 156),
|
||||
// TER-MCS-3.100: 2808 info bits
|
||||
2808 => (11, 156),
|
||||
// Generic fallback: search for valid QPP parameters.
|
||||
_ => find_qpp_params(block_size),
|
||||
}
|
||||
}
|
||||
|
||||
/// Search for valid QPP parameters for a given block size.
|
||||
///
|
||||
/// Tests (f1, f2) pairs to find one that produces a valid permutation
|
||||
/// (all indices unique).
|
||||
fn find_qpp_params(block_size: usize) -> (usize, usize) {
|
||||
if block_size <= 1 {
|
||||
return (1, 0);
|
||||
}
|
||||
// Try even f2 values with various f1
|
||||
for f2 in (2..block_size).step_by(2) {
|
||||
for f1 in 1..block_size {
|
||||
if is_valid_qpp(block_size, f1, f2) {
|
||||
return (f1, f2);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Last resort: simple coprime interleaver (f2=0)
|
||||
let f1 = find_coprime(block_size);
|
||||
(f1, 0)
|
||||
}
|
||||
|
||||
fn is_valid_qpp(block_size: usize, f1: usize, f2: usize) -> bool {
|
||||
let mut seen = vec![false; block_size];
|
||||
for i in 0..block_size {
|
||||
let idx = ((f1 as u64 * i as u64 + f2 as u64 * (i as u64 * i as u64)) % block_size as u64)
|
||||
as usize;
|
||||
if seen[idx] {
|
||||
return false;
|
||||
}
|
||||
seen[idx] = true;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Find a value coprime to n (for fallback interleaver).
|
||||
fn find_coprime(n: usize) -> usize {
|
||||
if n <= 1 {
|
||||
return 1;
|
||||
}
|
||||
for candidate in (1..n).rev() {
|
||||
if gcd(candidate, n) == 1 {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
1
|
||||
}
|
||||
|
||||
fn gcd(mut a: usize, mut b: usize) -> usize {
|
||||
while b != 0 {
|
||||
let t = b;
|
||||
b = a % b;
|
||||
a = t;
|
||||
}
|
||||
a
|
||||
}
|
||||
|
||||
/// Depuncture rate-1/2 turbo-coded stream.
|
||||
///
|
||||
/// ITU-R M.2092-1 rate-1/2 puncture pattern for TER-MCS-1:
|
||||
/// - Even positions: systematic + parity1 (encoder 1 output)
|
||||
/// - Odd positions: systematic + parity2 (encoder 2 output)
|
||||
///
|
||||
/// The transmitted stream alternates: [sys, p1, sys, p2, sys, p1, sys, p2, ...]
|
||||
///
|
||||
/// Input: received LLRs (positive = likely 0, negative = likely 1)
|
||||
/// Output: (systematic, parity1, parity2) LLR vectors
|
||||
pub fn depuncture_rate_half(
|
||||
received_llrs: &[Llr],
|
||||
info_len: usize,
|
||||
) -> (Vec<Llr>, Vec<Llr>, Vec<Llr>) {
|
||||
let mut systematic = vec![0.0; info_len];
|
||||
let mut parity1 = vec![0.0; info_len];
|
||||
let mut parity2 = vec![0.0; info_len];
|
||||
|
||||
// Rate 1/2: for each info bit, we have 2 coded bits.
|
||||
// Puncture pattern: [sys_i, p1_i] for even i, [sys_i, p2_i] for odd i
|
||||
// This means parity1 is available for even indices, parity2 for odd.
|
||||
let mut rx_idx = 0;
|
||||
for k in 0..info_len {
|
||||
if rx_idx < received_llrs.len() {
|
||||
systematic[k] = received_llrs[rx_idx];
|
||||
rx_idx += 1;
|
||||
}
|
||||
if k % 2 == 0 {
|
||||
// Parity from encoder 1
|
||||
if rx_idx < received_llrs.len() {
|
||||
parity1[k] = received_llrs[rx_idx];
|
||||
rx_idx += 1;
|
||||
}
|
||||
// Parity2 is punctured (erasure = 0.0 LLR, no information)
|
||||
} else {
|
||||
// Parity from encoder 2
|
||||
if rx_idx < received_llrs.len() {
|
||||
parity2[k] = received_llrs[rx_idx];
|
||||
rx_idx += 1;
|
||||
}
|
||||
// Parity1 is punctured
|
||||
}
|
||||
}
|
||||
|
||||
(systematic, parity1, parity2)
|
||||
}
|
||||
|
||||
/// Convert hard bits (0/1) to LLRs.
|
||||
///
|
||||
/// Uses a fixed reliability magnitude. 0 → +RELIABILITY, 1 → -RELIABILITY.
|
||||
pub fn hard_bits_to_llr(bits: &[u8]) -> Vec<Llr> {
|
||||
const RELIABILITY: Llr = 2.0;
|
||||
bits.iter()
|
||||
.map(|&b| if b == 0 { RELIABILITY } else { -RELIABILITY })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Main turbo decoder entry point.
|
||||
///
|
||||
/// Takes the received coded bits (hard decision), the information block
|
||||
/// length, and returns decoded information bits + a confidence metric.
|
||||
///
|
||||
/// Returns `(decoded_bits, avg_reliability)` where avg_reliability is the
|
||||
/// mean absolute LLR of the final decisions (higher = more confident).
|
||||
pub fn turbo_decode(coded_bits: &[u8], info_len: usize) -> (Vec<u8>, f32) {
|
||||
let received_llrs = hard_bits_to_llr(coded_bits);
|
||||
turbo_decode_soft(&received_llrs, info_len)
|
||||
}
|
||||
|
||||
/// Soft-input turbo decoder.
|
||||
pub fn turbo_decode_soft(received_llrs: &[Llr], info_len: usize) -> (Vec<u8>, f32) {
|
||||
if info_len == 0 {
|
||||
return (Vec::new(), 0.0);
|
||||
}
|
||||
|
||||
let interleaver = qpp_interleaver(info_len);
|
||||
debug_assert_eq!(
|
||||
interleaver.len(),
|
||||
info_len,
|
||||
"interleaver length must equal info_len"
|
||||
);
|
||||
let deinterleaver = invert_permutation(&interleaver);
|
||||
debug_assert_eq!(
|
||||
deinterleaver.len(),
|
||||
info_len,
|
||||
"deinterleaver length must equal info_len"
|
||||
);
|
||||
|
||||
let (sys_llr, par1_llr, par2_llr) = depuncture_rate_half(received_llrs, info_len);
|
||||
|
||||
// Interleaved systematic bits for decoder 2
|
||||
let sys_interleaved: Vec<Llr> = interleaver.iter().map(|&i| sys_llr[i]).collect();
|
||||
|
||||
// Extrinsic information passed between decoders
|
||||
let mut extrinsic_1_to_2 = vec![0.0_f32; info_len];
|
||||
let mut extrinsic_2_to_1 = vec![0.0_f32; info_len];
|
||||
|
||||
let mut final_llr = vec![0.0_f32; info_len];
|
||||
|
||||
for _iter in 0..TURBO_ITERATIONS {
|
||||
// --- Decoder 1 (natural order) ---
|
||||
let apriori_1: Vec<Llr> = deinterleaver.iter().map(|&i| extrinsic_2_to_1[i]).collect();
|
||||
let aposteriori_1 = bcjr_decode(&sys_llr, &par1_llr, &apriori_1);
|
||||
// Extrinsic = aposteriori - systematic - apriori
|
||||
for k in 0..info_len {
|
||||
extrinsic_1_to_2[k] = aposteriori_1[k] - sys_llr[k] - apriori_1[k];
|
||||
}
|
||||
|
||||
// --- Decoder 2 (interleaved order) ---
|
||||
let apriori_2: Vec<Llr> = interleaver.iter().map(|&i| extrinsic_1_to_2[i]).collect();
|
||||
let aposteriori_2 = bcjr_decode(&sys_interleaved, &par2_llr, &apriori_2);
|
||||
for k in 0..info_len {
|
||||
extrinsic_2_to_1[k] = aposteriori_2[k] - sys_interleaved[k] - apriori_2[k];
|
||||
}
|
||||
|
||||
// Combine for final decision (deinterleave decoder 2 output)
|
||||
for k in 0..info_len {
|
||||
let deint_apost2 = aposteriori_2[deinterleaver[k]];
|
||||
final_llr[k] =
|
||||
sys_llr[k] + extrinsic_1_to_2[k] + deint_apost2 - sys_llr[k] - extrinsic_1_to_2[k];
|
||||
// Simplified: final = systematic + extrinsic from both decoders
|
||||
final_llr[k] =
|
||||
sys_llr[k] + apriori_1[k] + (aposteriori_1[k] - sys_llr[k] - apriori_1[k]);
|
||||
}
|
||||
}
|
||||
|
||||
// Final decision: combine all information
|
||||
for k in 0..info_len {
|
||||
let apriori_1: Llr = if let Some(&di) = deinterleaver.get(k) {
|
||||
extrinsic_2_to_1[di]
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let aposteriori_1 = sys_llr[k] + apriori_1 + extrinsic_1_to_2[k];
|
||||
final_llr[k] = aposteriori_1;
|
||||
}
|
||||
|
||||
let decoded: Vec<u8> = final_llr
|
||||
.iter()
|
||||
.map(|&llr| if llr >= 0.0 { 0 } else { 1 })
|
||||
.collect();
|
||||
|
||||
let avg_reliability = if info_len > 0 {
|
||||
final_llr.iter().map(|l: &f32| l.abs()).sum::<f32>() / info_len as f32
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
(decoded, avg_reliability)
|
||||
}
|
||||
|
||||
/// Invert a permutation vector.
|
||||
fn invert_permutation(perm: &[usize]) -> Vec<usize> {
|
||||
let mut inv = vec![0usize; perm.len()];
|
||||
for (i, &p) in perm.iter().enumerate() {
|
||||
if p < inv.len() {
|
||||
inv[p] = i;
|
||||
}
|
||||
}
|
||||
inv
|
||||
}
|
||||
|
||||
/// BCJR (MAP) decoder for a single RSC constituent encoder.
|
||||
///
|
||||
/// Inputs:
|
||||
/// - `systematic`: channel LLRs for systematic bits
|
||||
/// - `parity`: channel LLRs for parity bits
|
||||
/// - `apriori`: a priori LLRs (extrinsic from other decoder)
|
||||
///
|
||||
/// Returns: a posteriori LLRs for each information bit.
|
||||
#[allow(clippy::needless_range_loop)]
|
||||
fn bcjr_decode(systematic: &[Llr], parity: &[Llr], apriori: &[Llr]) -> Vec<Llr> {
|
||||
let n = systematic.len();
|
||||
if n == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let total_len = n + TAIL_BITS;
|
||||
|
||||
// Extend parity for tail section
|
||||
let mut par_ext = vec![0.0_f32; total_len];
|
||||
par_ext[..parity.len().min(total_len)].copy_from_slice(&parity[..parity.len().min(total_len)]);
|
||||
|
||||
// --- Forward recursion (alpha) ---
|
||||
// alpha[t][s] = log P(state_t = s, y_1..t)
|
||||
let mut alpha = vec![vec![-LLR_INF; NUM_STATES]; total_len + 1];
|
||||
alpha[0][0] = 0.0; // Start in state 0
|
||||
|
||||
for t in 0..total_len {
|
||||
let sys_llr = if t < n {
|
||||
systematic[t] + apriori.get(t).copied().unwrap_or(0.0)
|
||||
} else {
|
||||
0.0 // Tail: force to zero state
|
||||
};
|
||||
|
||||
for s in 0..NUM_STATES {
|
||||
if alpha[t][s] <= -LLR_INF + 1.0 {
|
||||
continue;
|
||||
}
|
||||
for input in 0..=1u8 {
|
||||
let (next_state, parity_bit) = rsc_transition(s, input);
|
||||
let sys_metric = if input == 0 {
|
||||
sys_llr / 2.0
|
||||
} else {
|
||||
-sys_llr / 2.0
|
||||
};
|
||||
let par_metric = if parity_bit == 0 {
|
||||
par_ext[t] / 2.0
|
||||
} else {
|
||||
-par_ext[t] / 2.0
|
||||
};
|
||||
let branch = sys_metric + par_metric;
|
||||
alpha[t + 1][next_state] =
|
||||
log_sum_exp(alpha[t + 1][next_state], alpha[t][s] + branch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Backward recursion (beta) ---
|
||||
let mut beta = vec![vec![-LLR_INF; NUM_STATES]; total_len + 1];
|
||||
beta[total_len][0] = 0.0; // End in state 0 (after tail)
|
||||
|
||||
for t in (0..total_len).rev() {
|
||||
let sys_llr = if t < n {
|
||||
systematic[t] + apriori.get(t).copied().unwrap_or(0.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
for s in 0..NUM_STATES {
|
||||
for input in 0..=1u8 {
|
||||
let (next_state, parity_bit) = rsc_transition(s, input);
|
||||
if beta[t + 1][next_state] <= -LLR_INF + 1.0 {
|
||||
continue;
|
||||
}
|
||||
let sys_metric = if input == 0 {
|
||||
sys_llr / 2.0
|
||||
} else {
|
||||
-sys_llr / 2.0
|
||||
};
|
||||
let par_metric = if parity_bit == 0 {
|
||||
par_ext[t] / 2.0
|
||||
} else {
|
||||
-par_ext[t] / 2.0
|
||||
};
|
||||
let branch = sys_metric + par_metric;
|
||||
beta[t][s] = log_sum_exp(beta[t][s], beta[t + 1][next_state] + branch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- LLR computation ---
|
||||
let mut output_llr = vec![0.0_f32; n];
|
||||
for t in 0..n {
|
||||
let sys_llr_t = systematic[t] + apriori.get(t).copied().unwrap_or(0.0);
|
||||
let mut prob_0 = -LLR_INF;
|
||||
let mut prob_1 = -LLR_INF;
|
||||
|
||||
for s in 0..NUM_STATES {
|
||||
if alpha[t][s] <= -LLR_INF + 1.0 {
|
||||
continue;
|
||||
}
|
||||
for input in 0..=1u8 {
|
||||
let (next_state, parity_bit) = rsc_transition(s, input);
|
||||
if beta[t + 1][next_state] <= -LLR_INF + 1.0 {
|
||||
continue;
|
||||
}
|
||||
let sys_metric = if input == 0 {
|
||||
sys_llr_t / 2.0
|
||||
} else {
|
||||
-sys_llr_t / 2.0
|
||||
};
|
||||
let par_metric = if parity_bit == 0 {
|
||||
par_ext[t] / 2.0
|
||||
} else {
|
||||
-par_ext[t] / 2.0
|
||||
};
|
||||
let gamma = sys_metric + par_metric;
|
||||
let metric = alpha[t][s] + gamma + beta[t + 1][next_state];
|
||||
|
||||
if input == 0 {
|
||||
prob_0 = log_sum_exp(prob_0, metric);
|
||||
} else {
|
||||
prob_1 = log_sum_exp(prob_1, metric);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
output_llr[t] = prob_0 - prob_1;
|
||||
}
|
||||
|
||||
output_llr
|
||||
}
|
||||
|
||||
/// RSC encoder state transition.
|
||||
///
|
||||
/// Given current state and input bit, returns (next_state, parity_output).
|
||||
///
|
||||
/// The RSC encoder uses:
|
||||
/// - Feedback polynomial: g_fb = 1 + D + D^3 (octal 013)
|
||||
/// - Feedforward polynomial: g_ff = 1 + D^2 + D^3 (octal 015)
|
||||
///
|
||||
/// State is the shift register content (3 bits for K=4).
|
||||
fn rsc_transition(state: usize, input: u8) -> (usize, u8) {
|
||||
let s = state as u8;
|
||||
|
||||
// Feedback: XOR of input with feedback taps
|
||||
let feedback = input ^ parity_of(s & (FB_POLY >> 1));
|
||||
|
||||
// New state: shift in the feedback bit
|
||||
let next_state = (((s << 1) | feedback) & 0x07) as usize;
|
||||
|
||||
// Parity output: feedforward taps applied to new register contents
|
||||
let reg_with_input = (feedback << 3) | s;
|
||||
let parity = parity_of(reg_with_input & FF_POLY);
|
||||
|
||||
(next_state, parity)
|
||||
}
|
||||
|
||||
/// Compute parity (XOR of all set bits) of a byte value.
|
||||
fn parity_of(val: u8) -> u8 {
|
||||
(val.count_ones() as u8) & 1
|
||||
}
|
||||
|
||||
/// Numerically stable log-sum-exp: log(exp(a) + exp(b)).
|
||||
///
|
||||
/// Uses the Jacobian logarithm approximation for speed, with a correction
|
||||
/// table for improved accuracy.
|
||||
fn log_sum_exp(a: Llr, b: Llr) -> Llr {
|
||||
if a <= -LLR_INF + 1.0 {
|
||||
return b;
|
||||
}
|
||||
if b <= -LLR_INF + 1.0 {
|
||||
return a;
|
||||
}
|
||||
let max = a.max(b);
|
||||
let diff = (a - b).abs();
|
||||
// Correction term: log(1 + exp(-|diff|))
|
||||
let correction = if diff > 5.0 {
|
||||
0.0
|
||||
} else {
|
||||
(1.0 + (-diff).exp()).ln()
|
||||
};
|
||||
max + correction
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn qpp_interleaver_is_valid_permutation() {
|
||||
for &size in &[468, 936, 1872] {
|
||||
let perm = qpp_interleaver(size);
|
||||
assert_eq!(perm.len(), size);
|
||||
let mut seen = vec![false; size];
|
||||
for &idx in &perm {
|
||||
assert!(idx < size, "index {} out of range for size {}", idx, size);
|
||||
assert!(!seen[idx], "duplicate index {} for size {}", idx, size);
|
||||
seen[idx] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rsc_transition_state_zero_input_zero() {
|
||||
let (next, par) = rsc_transition(0, 0);
|
||||
assert_eq!(next, 0);
|
||||
assert_eq!(par, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rsc_transition_all_states_valid() {
|
||||
for state in 0..NUM_STATES {
|
||||
for input in 0..=1u8 {
|
||||
let (next, par) = rsc_transition(state, input);
|
||||
assert!(next < NUM_STATES);
|
||||
assert!(par <= 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turbo_decode_all_zeros() {
|
||||
let info_len = 40;
|
||||
// Encode all-zeros: systematic=0, parity=0 for both encoders
|
||||
let coded_len = info_len * 2;
|
||||
let coded_bits = vec![0u8; coded_len];
|
||||
let (decoded, reliability) = turbo_decode(&coded_bits, info_len);
|
||||
assert_eq!(decoded.len(), info_len);
|
||||
// All-zeros input should decode to all zeros
|
||||
assert!(decoded.iter().all(|&b| b == 0), "decoded: {:?}", decoded);
|
||||
assert!(reliability > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turbo_decode_handles_empty() {
|
||||
let (decoded, reliability) = turbo_decode(&[], 0);
|
||||
assert!(decoded.is_empty());
|
||||
assert_eq!(reliability, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log_sum_exp_correctness() {
|
||||
let a = 2.0f32;
|
||||
let b = 3.0f32;
|
||||
let expected = (a.exp() + b.exp()).ln();
|
||||
let result = log_sum_exp(a, b);
|
||||
assert!(
|
||||
(result - expected).abs() < 0.01,
|
||||
"got {}, expected {}",
|
||||
result,
|
||||
expected
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invert_permutation_round_trips() {
|
||||
let perm = qpp_interleaver(40);
|
||||
let inv = invert_permutation(&perm);
|
||||
for (i, &p) in perm.iter().enumerate() {
|
||||
assert_eq!(inv[p], i);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn depuncture_produces_correct_lengths() {
|
||||
let info_len = 100;
|
||||
let coded = vec![0u8; info_len * 2];
|
||||
let llrs = hard_bits_to_llr(&coded);
|
||||
let (sys, p1, p2) = depuncture_rate_half(&llrs, info_len);
|
||||
assert_eq!(sys.len(), info_len);
|
||||
assert_eq!(p1.len(), info_len);
|
||||
assert_eq!(p2.len(), info_len);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
[package]
|
||||
name = "trx-wefax"
|
||||
version.workspace = true
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
trx-core = { path = "../../trx-core" }
|
||||
base64 = "0.22"
|
||||
png = "0.17"
|
||||
tracing = "0.1"
|
||||
@@ -0,0 +1,52 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! WEFAX decoder configuration.
|
||||
|
||||
/// Configuration for the WEFAX decoder.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WefaxConfig {
|
||||
/// Lines per minute: 60, 90, 120, 240. `None` = auto-detect from APT.
|
||||
pub lpm: Option<u16>,
|
||||
/// Index of Cooperation: 288 or 576. `None` = auto-detect from start tone.
|
||||
pub ioc: Option<u16>,
|
||||
/// Centre frequency of the FM subcarrier (default 1900 Hz).
|
||||
pub center_freq_hz: f32,
|
||||
/// Deviation (default ±400 Hz, so black=1500, white=2300).
|
||||
pub deviation_hz: f32,
|
||||
/// Directory for saving decoded images.
|
||||
pub output_dir: Option<String>,
|
||||
/// Whether to emit line-by-line progress events.
|
||||
pub emit_progress: bool,
|
||||
}
|
||||
|
||||
impl Default for WefaxConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
lpm: None,
|
||||
ioc: None,
|
||||
center_freq_hz: 1900.0,
|
||||
deviation_hz: 400.0,
|
||||
output_dir: None,
|
||||
emit_progress: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WefaxConfig {
|
||||
/// Pixels per line for a given IOC value: `IOC × π`, rounded.
|
||||
pub fn pixels_per_line(ioc: u16) -> u16 {
|
||||
(f64::from(ioc) * std::f64::consts::PI).round() as u16
|
||||
}
|
||||
|
||||
/// Line duration in seconds for a given LPM value.
|
||||
pub fn line_duration_s(lpm: u16) -> f32 {
|
||||
60.0 / lpm as f32
|
||||
}
|
||||
|
||||
/// Samples per line at the internal sample rate.
|
||||
pub fn samples_per_line(lpm: u16, sample_rate: u32) -> usize {
|
||||
(Self::line_duration_s(lpm) * sample_rate as f32).round() as usize
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,599 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! Top-level WEFAX decoder state machine.
|
||||
//!
|
||||
//! Drives the DSP pipeline: resampler → FM discriminator → tone detector →
|
||||
//! phasing → line slicer → image assembler.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use base64::Engine;
|
||||
use trx_core::decode::{WefaxMessage, WefaxProgress};
|
||||
|
||||
use tracing::{debug, trace};
|
||||
|
||||
use crate::config::WefaxConfig;
|
||||
use crate::demod::FmDiscriminator;
|
||||
use crate::image::ImageAssembler;
|
||||
use crate::line_slicer::LineSlicer;
|
||||
use crate::phase::PhasingDetector;
|
||||
use crate::resampler::{Resampler, INTERNAL_RATE};
|
||||
use crate::tone_detect::{AptTone, ToneDetector};
|
||||
|
||||
/// Progress events are emitted every this many lines.
|
||||
const PROGRESS_INTERVAL: u32 = 5;
|
||||
|
||||
/// Minimum luminance standard deviation to consider a window as containing
|
||||
/// active WEFAX signal (image data has varied luminance; silence/noise is flat).
|
||||
const SIGNAL_DETECT_MIN_STDDEV: f32 = 0.08;
|
||||
|
||||
/// Number of consecutive active-signal windows needed to auto-start receiving.
|
||||
/// At 0.5 s per window this is ~3 seconds.
|
||||
const SIGNAL_DETECT_WINDOWS: u32 = 6;
|
||||
|
||||
/// Pearson correlation below which a new scan line is considered uncorrelated
|
||||
/// with its predecessor — i.e. the slicer is looking at noise, not imagery.
|
||||
/// Real WEFAX content typically shows r > 0.5 between adjacent lines.
|
||||
const LINE_CORR_NOISE_THRESHOLD: f32 = 0.2;
|
||||
|
||||
/// Number of consecutive uncorrelated scan lines that trigger auto-finalize
|
||||
/// while receiving. At 120 LPM this is 15 s; at 60 LPM it's 30 s. Modelled on
|
||||
/// fldigi's line-to-line correlation check for automatic stop.
|
||||
const LINE_CORR_NOISE_LINES: u32 = 30;
|
||||
|
||||
/// Maximum number of scan-line-equivalent sample windows to wait for phasing
|
||||
/// lock before falling through to Receiving. Typical WEFAX phasing lasts
|
||||
/// ~30 s; if the phasing detector hasn't converged by then we give up on
|
||||
/// alignment and let the carrier-loss watchdog decide whether the content
|
||||
/// that follows is real imagery. At 120 LPM this is ~30 s.
|
||||
const PHASING_TIMEOUT_LINES: u32 = 60;
|
||||
|
||||
/// WEFAX decoder output event.
|
||||
#[derive(Debug)]
|
||||
pub enum WefaxEvent {
|
||||
/// A progress update with line data for live rendering.
|
||||
Progress(WefaxProgress, Vec<u8>),
|
||||
/// A completed image.
|
||||
Complete(WefaxMessage),
|
||||
}
|
||||
|
||||
/// Internal decoder state.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum State {
|
||||
/// Listening for APT start tone.
|
||||
Idle,
|
||||
/// Start tone detected; waiting for phasing signal.
|
||||
StartDetected { ioc: u16 },
|
||||
/// Receiving phasing lines; aligning line-start phase.
|
||||
Phasing { ioc: u16, lpm: u16 },
|
||||
/// Actively decoding image lines.
|
||||
Receiving { ioc: u16, lpm: u16 },
|
||||
/// Stop tone detected; finalising image.
|
||||
Stopping { ioc: u16, lpm: u16 },
|
||||
}
|
||||
|
||||
/// Top-level WEFAX decoder.
|
||||
pub struct WefaxDecoder {
|
||||
config: WefaxConfig,
|
||||
state: State,
|
||||
resampler: Resampler,
|
||||
demodulator: FmDiscriminator,
|
||||
tone_detector: ToneDetector,
|
||||
phasing: Option<PhasingDetector>,
|
||||
slicer: Option<LineSlicer>,
|
||||
image: Option<ImageAssembler>,
|
||||
/// Total sample counter for timestamps.
|
||||
sample_count: u64,
|
||||
/// Timestamp (ms since epoch) when reception started.
|
||||
reception_start_ms: Option<i64>,
|
||||
/// Whether the initial "Idle" state event has been emitted.
|
||||
sent_idle_event: bool,
|
||||
/// Counts consecutive half-second windows where the luminance variance is
|
||||
/// high enough to indicate an active WEFAX transmission. Used to auto-start
|
||||
/// receiving when tuning in mid-image (same idea as fldigi's "strong image
|
||||
/// signal" detection in `fax_signal`).
|
||||
signal_detect_count: u32,
|
||||
/// Accumulator for computing luminance variance within the current window.
|
||||
signal_detect_buf: Vec<f32>,
|
||||
/// Counts consecutive scan lines whose correlation with the previous
|
||||
/// line falls below `LINE_CORR_NOISE_THRESHOLD`. When it reaches
|
||||
/// `LINE_CORR_NOISE_LINES` the decoder auto-finalizes the in-progress
|
||||
/// image (carrier dropped / tx ended without an APT stop tone).
|
||||
low_corr_lines: u32,
|
||||
/// Number of luminance samples processed while in `State::Phasing`.
|
||||
/// When this exceeds the equivalent of `PHASING_TIMEOUT_LINES` lines,
|
||||
/// the decoder falls through to Receiving so a noisy or partial
|
||||
/// phasing signal doesn't wedge the state machine.
|
||||
phasing_samples: u64,
|
||||
/// Current rig dial frequency in Hz (for image filenames).
|
||||
freq_hz: u64,
|
||||
/// Current rig mode name (for image filenames).
|
||||
mode: String,
|
||||
}
|
||||
|
||||
impl WefaxDecoder {
|
||||
pub fn new(input_sample_rate: u32, config: WefaxConfig) -> Self {
|
||||
Self {
|
||||
resampler: Resampler::new(input_sample_rate),
|
||||
demodulator: FmDiscriminator::new(
|
||||
INTERNAL_RATE,
|
||||
config.center_freq_hz,
|
||||
config.deviation_hz,
|
||||
),
|
||||
tone_detector: ToneDetector::new(INTERNAL_RATE),
|
||||
config,
|
||||
state: State::Idle,
|
||||
phasing: None,
|
||||
slicer: None,
|
||||
image: None,
|
||||
sample_count: 0,
|
||||
reception_start_ms: None,
|
||||
sent_idle_event: false,
|
||||
signal_detect_count: 0,
|
||||
signal_detect_buf: Vec::with_capacity(INTERNAL_RATE as usize / 2),
|
||||
low_corr_lines: 0,
|
||||
phasing_samples: 0,
|
||||
freq_hz: 0,
|
||||
mode: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Process a block of PCM audio samples (mono, at the input sample rate).
|
||||
///
|
||||
/// Returns any events generated during processing.
|
||||
pub fn process_samples(&mut self, samples: &[f32]) -> Vec<WefaxEvent> {
|
||||
self.sample_count += samples.len() as u64;
|
||||
let mut events = Vec::new();
|
||||
|
||||
// Emit an initial "Idle" state event so the frontend knows the decoder is processing audio.
|
||||
if !self.sent_idle_event {
|
||||
self.sent_idle_event = true;
|
||||
let ioc = self.config.ioc.unwrap_or(576);
|
||||
let lpm = self.config.lpm.unwrap_or(120);
|
||||
events.push(self.state_event("Idle \u{2014} scanning", ioc, lpm));
|
||||
}
|
||||
|
||||
// Step 1: Resample to internal rate.
|
||||
let resampled = self.resampler.process(samples);
|
||||
|
||||
// Step 2: FM demodulate to get luminance values.
|
||||
let luminance = self.demodulator.process(&resampled);
|
||||
|
||||
// Periodic luminance stats for diagnostics (every ~5 seconds at 11025 Hz).
|
||||
if self.sample_count % (INTERNAL_RATE as u64 * 5) < samples.len() as u64
|
||||
&& !luminance.is_empty()
|
||||
{
|
||||
let min = luminance.iter().cloned().fold(f32::INFINITY, f32::min);
|
||||
let max = luminance.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
|
||||
let mean = luminance.iter().sum::<f32>() / luminance.len() as f32;
|
||||
trace!(
|
||||
min = format!("{:.3}", min),
|
||||
max = format!("{:.3}", max),
|
||||
mean = format!("{:.3}", mean),
|
||||
n = luminance.len(),
|
||||
state = ?self.state,
|
||||
"WEFAX luminance stats"
|
||||
);
|
||||
}
|
||||
|
||||
// Step 3: Run APT detector on demodulated luminance (transition counting).
|
||||
let tone_results = self.tone_detector.process(&luminance);
|
||||
|
||||
// Step 4: Process based on current state.
|
||||
match self.state.clone() {
|
||||
State::Idle => {
|
||||
// Look for APT start tone first.
|
||||
for result in &tone_results {
|
||||
if let Some(tone) = result.tone {
|
||||
match tone {
|
||||
AptTone::Start576 => {
|
||||
events.push(self.transition_to_start_detected(576));
|
||||
break;
|
||||
}
|
||||
AptTone::Start288 => {
|
||||
events.push(self.transition_to_start_detected(288));
|
||||
break;
|
||||
}
|
||||
AptTone::Stop => {} // Ignore stop in idle.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: detect active WEFAX signal by luminance variance.
|
||||
// Like fldigi's "strong image signal" detection — if we see
|
||||
// sustained modulated signal, auto-start receiving with defaults.
|
||||
if self.state == State::Idle {
|
||||
self.signal_detect_buf.extend_from_slice(&luminance);
|
||||
let window_size = INTERNAL_RATE as usize / 2;
|
||||
while self.signal_detect_buf.len() >= window_size {
|
||||
let window = &self.signal_detect_buf[..window_size];
|
||||
let mean = window.iter().sum::<f32>() / window.len() as f32;
|
||||
let variance = window
|
||||
.iter()
|
||||
.map(|&v| {
|
||||
let d = v - mean;
|
||||
d * d
|
||||
})
|
||||
.sum::<f32>()
|
||||
/ window.len() as f32;
|
||||
let stddev = variance.sqrt();
|
||||
|
||||
if stddev > SIGNAL_DETECT_MIN_STDDEV {
|
||||
self.signal_detect_count += 1;
|
||||
trace!(
|
||||
stddev = format!("{:.4}", stddev),
|
||||
count = self.signal_detect_count,
|
||||
"WEFAX signal detected"
|
||||
);
|
||||
} else {
|
||||
self.signal_detect_count = 0;
|
||||
}
|
||||
|
||||
if self.signal_detect_count >= SIGNAL_DETECT_WINDOWS {
|
||||
let ioc = self.config.ioc.unwrap_or(576);
|
||||
let lpm = self.config.lpm.unwrap_or(120);
|
||||
debug!(ioc, lpm, "WEFAX: auto-start from signal detection");
|
||||
self.reception_start_ms = Some(
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as i64,
|
||||
);
|
||||
self.signal_detect_buf.clear();
|
||||
events.push(self.transition_to_receiving(ioc, lpm, 0));
|
||||
break;
|
||||
}
|
||||
|
||||
self.signal_detect_buf.drain(..window_size);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
State::StartDetected { ioc } => {
|
||||
// Wait for tone to end (no more start tone detected), then
|
||||
// transition to phasing.
|
||||
let still_start = tone_results
|
||||
.iter()
|
||||
.any(|r| matches!(r.tone, Some(AptTone::Start576 | AptTone::Start288)));
|
||||
|
||||
if !still_start {
|
||||
events.push(self.transition_to_phasing(ioc));
|
||||
}
|
||||
}
|
||||
|
||||
State::Phasing { ioc, lpm } => {
|
||||
// Check for stop tone (abort).
|
||||
if tone_results.iter().any(|r| r.tone == Some(AptTone::Stop)) {
|
||||
self.transition_to_idle();
|
||||
return events;
|
||||
}
|
||||
|
||||
if let Some(ref mut phasing) = self.phasing {
|
||||
if let Some(offset) = phasing.process(&luminance) {
|
||||
events.push(self.transition_to_receiving(ioc, lpm, offset));
|
||||
} else {
|
||||
// Phasing timeout: if alignment doesn't converge in
|
||||
// ~PHASING_TIMEOUT_LINES lines, fall through to
|
||||
// Receiving and let the carrier-loss watchdog decide
|
||||
// whether the content that follows is real imagery.
|
||||
self.phasing_samples += luminance.len() as u64;
|
||||
let spl = WefaxConfig::samples_per_line(lpm, INTERNAL_RATE) as u64;
|
||||
if self.phasing_samples >= spl * PHASING_TIMEOUT_LINES as u64 {
|
||||
debug!(
|
||||
ioc,
|
||||
lpm, "WEFAX: phasing timeout — falling through to receiving"
|
||||
);
|
||||
events.push(self.transition_to_receiving(ioc, lpm, 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
State::Receiving { ioc, lpm } => {
|
||||
// Check for stop tone.
|
||||
if tone_results.iter().any(|r| r.tone == Some(AptTone::Stop)) {
|
||||
self.state = State::Stopping { ioc, lpm };
|
||||
events.extend(self.finalize_image(ioc, lpm));
|
||||
self.transition_to_idle();
|
||||
return events;
|
||||
}
|
||||
|
||||
// Feed luminance to line slicer.
|
||||
let mut carrier_lost = false;
|
||||
if let Some(ref mut slicer) = self.slicer {
|
||||
let new_lines = slicer.process(&luminance);
|
||||
for line in new_lines {
|
||||
if let Some(ref mut image) = self.image {
|
||||
// Carrier-loss watchdog: real imagery has highly
|
||||
// correlated adjacent lines; pure noise does not.
|
||||
// After LINE_CORR_NOISE_LINES consecutive low-
|
||||
// correlation lines we finalize (fldigi-style
|
||||
// automatic stop).
|
||||
if let Some(r) = image.correlation_with_last(&line) {
|
||||
if r < LINE_CORR_NOISE_THRESHOLD {
|
||||
self.low_corr_lines += 1;
|
||||
trace!(
|
||||
r = format!("{:.3}", r),
|
||||
count = self.low_corr_lines,
|
||||
"WEFAX low line-correlation"
|
||||
);
|
||||
} else {
|
||||
self.low_corr_lines = 0;
|
||||
}
|
||||
}
|
||||
// Flat lines (correlation == None) don't advance
|
||||
// the counter but also don't reset it — an image
|
||||
// with a solid band surrounded by noise still
|
||||
// trips the watchdog once the noise resumes.
|
||||
|
||||
image.push_line(line);
|
||||
let count = image.line_count();
|
||||
|
||||
if self.low_corr_lines >= LINE_CORR_NOISE_LINES {
|
||||
debug!(
|
||||
lines = count,
|
||||
"WEFAX: line correlation lost — auto-finalizing image"
|
||||
);
|
||||
carrier_lost = true;
|
||||
break;
|
||||
}
|
||||
|
||||
// Emit progress event.
|
||||
if self.config.emit_progress && count % PROGRESS_INTERVAL == 0 {
|
||||
let line_data =
|
||||
image.last_line().map(|l| l.to_vec()).unwrap_or_default();
|
||||
let b64 =
|
||||
base64::engine::general_purpose::STANDARD.encode(&line_data);
|
||||
events.push(WefaxEvent::Progress(
|
||||
WefaxProgress {
|
||||
rig_id: None,
|
||||
line_count: count,
|
||||
lpm,
|
||||
ioc,
|
||||
pixels_per_line: WefaxConfig::pixels_per_line(ioc),
|
||||
line_data: Some(b64),
|
||||
state: None,
|
||||
},
|
||||
line_data,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if carrier_lost {
|
||||
events.extend(self.finalize_image(ioc, lpm));
|
||||
self.transition_to_idle();
|
||||
return events;
|
||||
}
|
||||
}
|
||||
|
||||
State::Stopping { .. } => {
|
||||
// Already handled, transition back to idle.
|
||||
self.transition_to_idle();
|
||||
}
|
||||
}
|
||||
|
||||
events
|
||||
}
|
||||
|
||||
/// Reset the decoder. Saves the in-progress image (if any) before
|
||||
/// returning to Idle. Returns any completion events produced.
|
||||
pub fn reset(&mut self) -> Vec<WefaxEvent> {
|
||||
let events = match self.state {
|
||||
State::Receiving { ioc, lpm } | State::Phasing { ioc, lpm } => {
|
||||
self.finalize_image(ioc, lpm)
|
||||
}
|
||||
_ => Vec::new(),
|
||||
};
|
||||
self.state = State::Idle;
|
||||
self.resampler.reset();
|
||||
self.demodulator.reset();
|
||||
self.tone_detector.reset();
|
||||
self.phasing = None;
|
||||
self.slicer = None;
|
||||
self.image = None;
|
||||
self.sample_count = 0;
|
||||
self.reception_start_ms = None;
|
||||
self.sent_idle_event = false;
|
||||
self.signal_detect_count = 0;
|
||||
self.signal_detect_buf.clear();
|
||||
self.low_corr_lines = 0;
|
||||
self.phasing_samples = 0;
|
||||
events
|
||||
}
|
||||
|
||||
/// Update the current rig tuning (used for image filenames).
|
||||
pub fn set_tuning(&mut self, freq_hz: u64, mode: &str) {
|
||||
self.freq_hz = freq_hz;
|
||||
self.mode = mode.to_string();
|
||||
}
|
||||
|
||||
/// Check if the decoder is currently receiving an image.
|
||||
pub fn is_receiving(&self) -> bool {
|
||||
matches!(self.state, State::Phasing { .. } | State::Receiving { .. })
|
||||
}
|
||||
|
||||
fn state_event(&self, label: &str, ioc: u16, lpm: u16) -> WefaxEvent {
|
||||
WefaxEvent::Progress(
|
||||
WefaxProgress {
|
||||
rig_id: None,
|
||||
line_count: 0,
|
||||
lpm,
|
||||
ioc,
|
||||
pixels_per_line: WefaxConfig::pixels_per_line(ioc),
|
||||
line_data: None,
|
||||
state: Some(label.to_string()),
|
||||
},
|
||||
Vec::new(),
|
||||
)
|
||||
}
|
||||
|
||||
fn transition_to_start_detected(&mut self, ioc: u16) -> WefaxEvent {
|
||||
let ioc = self.config.ioc.unwrap_or(ioc);
|
||||
debug!(ioc, "WEFAX: APT start detected");
|
||||
self.state = State::StartDetected { ioc };
|
||||
self.reception_start_ms = Some(
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as i64,
|
||||
);
|
||||
let lpm = self.config.lpm.unwrap_or(120);
|
||||
self.state_event(&format!("APT Start {}", ioc), ioc, lpm)
|
||||
}
|
||||
|
||||
fn transition_to_phasing(&mut self, ioc: u16) -> WefaxEvent {
|
||||
let lpm = self.config.lpm.unwrap_or(120); // Default 120 LPM.
|
||||
debug!(ioc, lpm, "WEFAX: entering phasing");
|
||||
self.tone_detector.reset();
|
||||
self.phasing = Some(PhasingDetector::new(lpm, INTERNAL_RATE));
|
||||
self.demodulator.reset();
|
||||
self.phasing_samples = 0;
|
||||
self.state = State::Phasing { ioc, lpm };
|
||||
self.state_event("Phasing", ioc, lpm)
|
||||
}
|
||||
|
||||
fn transition_to_receiving(&mut self, ioc: u16, lpm: u16, phase_offset: usize) -> WefaxEvent {
|
||||
debug!(ioc, lpm, phase_offset, "WEFAX: entering receiving");
|
||||
let ppl = WefaxConfig::pixels_per_line(ioc) as usize;
|
||||
self.slicer = Some(LineSlicer::new(lpm, ioc, INTERNAL_RATE, phase_offset));
|
||||
self.image = Some(ImageAssembler::new(ppl));
|
||||
self.tone_detector.reset();
|
||||
self.low_corr_lines = 0;
|
||||
self.state = State::Receiving { ioc, lpm };
|
||||
self.state_event("Receiving", ioc, lpm)
|
||||
}
|
||||
|
||||
fn transition_to_idle(&mut self) {
|
||||
self.state = State::Idle;
|
||||
self.phasing = None;
|
||||
self.slicer = None;
|
||||
// image is kept until finalize_image is called or next reception starts.
|
||||
self.tone_detector.reset();
|
||||
self.signal_detect_count = 0;
|
||||
self.signal_detect_buf.clear();
|
||||
self.low_corr_lines = 0;
|
||||
self.phasing_samples = 0;
|
||||
}
|
||||
|
||||
fn finalize_image(&mut self, ioc: u16, lpm: u16) -> Vec<WefaxEvent> {
|
||||
let mut events = Vec::new();
|
||||
|
||||
if let Some(ref image) = self.image {
|
||||
if image.line_count() == 0 {
|
||||
return events;
|
||||
}
|
||||
|
||||
let ppl = WefaxConfig::pixels_per_line(ioc);
|
||||
let mut path_str = None;
|
||||
let mut png_data = None;
|
||||
|
||||
// Save PNG if output directory is configured.
|
||||
if let Some(ref dir) = self.config.output_dir {
|
||||
let output_path = PathBuf::from(dir);
|
||||
match image.save_png(&output_path, self.freq_hz, &self.mode) {
|
||||
Ok(p) => {
|
||||
// Read back the PNG bytes for remote client transfer.
|
||||
match std::fs::read(&p) {
|
||||
Ok(bytes) => {
|
||||
png_data =
|
||||
Some(base64::engine::general_purpose::STANDARD.encode(&bytes));
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("WEFAX: failed to read PNG for transfer: {}", e);
|
||||
}
|
||||
}
|
||||
path_str = Some(p.to_string_lossy().into_owned());
|
||||
}
|
||||
Err(e) => {
|
||||
// Log the error but still emit the completion event.
|
||||
eprintln!("WEFAX: failed to save PNG: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
events.push(WefaxEvent::Complete(WefaxMessage {
|
||||
rig_id: None,
|
||||
ts_ms: self.reception_start_ms,
|
||||
line_count: image.line_count(),
|
||||
lpm,
|
||||
ioc,
|
||||
pixels_per_line: ppl,
|
||||
path: path_str,
|
||||
png_data,
|
||||
complete: true,
|
||||
}));
|
||||
}
|
||||
|
||||
self.image = None;
|
||||
self.reception_start_ms = None;
|
||||
events
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::f32::consts::PI;
|
||||
|
||||
/// Generate an FM-modulated WEFAX APT start signal.
|
||||
///
|
||||
/// The APT start signal alternates between black (1500 Hz) and white
|
||||
/// (2300 Hz) at the given transition rate, FM-modulated onto the 1900 Hz
|
||||
/// subcarrier.
|
||||
fn generate_apt_start(trans_freq: f32, sample_rate: u32, duration_s: f32) -> Vec<f32> {
|
||||
let n = (sample_rate as f32 * duration_s) as usize;
|
||||
let center = 1900.0f32;
|
||||
let deviation = 400.0f32;
|
||||
let mut phase = 0.0f64;
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
// Square wave modulation at trans_freq.
|
||||
let t = i as f32 / sample_rate as f32;
|
||||
let mod_sign = if (2.0 * PI * trans_freq * t).sin() >= 0.0 {
|
||||
1.0
|
||||
} else {
|
||||
-1.0
|
||||
};
|
||||
let inst_freq = center + deviation * mod_sign;
|
||||
phase += 2.0 * std::f64::consts::PI * inst_freq as f64 / sample_rate as f64;
|
||||
phase.sin() as f32
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_starts_idle() {
|
||||
let dec = WefaxDecoder::new(48000, WefaxConfig::default());
|
||||
assert_eq!(dec.state, State::Idle);
|
||||
assert!(!dec.is_receiving());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_detects_start_tone() {
|
||||
let mut dec = WefaxDecoder::new(11025, WefaxConfig::default());
|
||||
// Feed 3 seconds of APT start signal (300 transitions/s, IOC 576)
|
||||
// at internal sample rate (bypass resampler).
|
||||
let signal = generate_apt_start(300.0, 11025, 3.0);
|
||||
dec.process_samples(&signal);
|
||||
assert!(
|
||||
matches!(
|
||||
dec.state,
|
||||
State::StartDetected { ioc: 576 } | State::Phasing { ioc: 576, .. }
|
||||
),
|
||||
"state should be StartDetected or Phasing, got {:?}",
|
||||
dec.state
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_reset_returns_to_idle() {
|
||||
let mut dec = WefaxDecoder::new(48000, WefaxConfig::default());
|
||||
dec.state = State::Receiving { ioc: 576, lpm: 120 };
|
||||
dec.reset();
|
||||
assert_eq!(dec.state, State::Idle);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! FM discriminator for WEFAX demodulation.
|
||||
//!
|
||||
//! Computes instantaneous frequency from the analytic signal produced by a
|
||||
//! Hilbert transform FIR, then maps the frequency to a 0.0–1.0 luminance
|
||||
//! value (1500 Hz = black, 2300 Hz = white).
|
||||
//!
|
||||
//! Uses block-based linear processing for auto-vectorisation of the FIR
|
||||
//! convolution, consistent with `docs/Optimization-Guidelines.md`.
|
||||
|
||||
use std::f32::consts::PI;
|
||||
|
||||
/// Number of taps for the Hilbert transform FIR.
|
||||
const HILBERT_TAPS: usize = 65;
|
||||
|
||||
/// Half the Hilbert FIR length (group delay in samples).
|
||||
const HILBERT_DELAY: usize = HILBERT_TAPS / 2;
|
||||
|
||||
/// FM discriminator producing luminance values from audio samples.
|
||||
pub struct FmDiscriminator {
|
||||
/// Hilbert FIR coefficients (odd-length, anti-symmetric).
|
||||
hilbert_coeffs: [f32; HILBERT_TAPS],
|
||||
/// Tail buffer: last `HILBERT_TAPS - 1` input samples from the previous
|
||||
/// block (used to prime the next convolution without modular indexing).
|
||||
tail: Vec<f32>,
|
||||
/// Previous analytic signal sample for frequency differentiation.
|
||||
prev_i: f32,
|
||||
prev_q: f32,
|
||||
/// Pre-computed constants.
|
||||
inv_2pi_ts: f32,
|
||||
black_hz: f32,
|
||||
inv_range_hz: f32,
|
||||
}
|
||||
|
||||
impl FmDiscriminator {
|
||||
pub fn new(sample_rate: u32, center_hz: f32, deviation_hz: f32) -> Self {
|
||||
let coeffs = design_hilbert_fir();
|
||||
let sr = sample_rate as f32;
|
||||
Self {
|
||||
hilbert_coeffs: coeffs,
|
||||
tail: vec![0.0; HILBERT_TAPS - 1],
|
||||
prev_i: 0.0,
|
||||
prev_q: 0.0,
|
||||
inv_2pi_ts: sr / (2.0 * PI),
|
||||
black_hz: center_hz - deviation_hz,
|
||||
inv_range_hz: 1.0 / (2.0 * deviation_hz),
|
||||
}
|
||||
}
|
||||
|
||||
/// Process a block of real-valued audio samples, returning luminance
|
||||
/// values in the range 0.0 (black / 1500 Hz) to 1.0 (white / 2300 Hz).
|
||||
///
|
||||
/// The Hilbert FIR is evaluated on a contiguous linear buffer
|
||||
/// (`[tail | samples]`) so the inner loop uses straight indexing—no
|
||||
/// modular arithmetic—and the compiler can auto-vectorise.
|
||||
pub fn process(&mut self, samples: &[f32]) -> Vec<f32> {
|
||||
let n = HILBERT_TAPS;
|
||||
let half = HILBERT_DELAY;
|
||||
let tail_len = n - 1;
|
||||
|
||||
// Build contiguous work buffer: [tail from previous block | new samples].
|
||||
let work_len = tail_len + samples.len();
|
||||
let mut work = Vec::with_capacity(work_len);
|
||||
work.extend_from_slice(&self.tail);
|
||||
work.extend_from_slice(samples);
|
||||
|
||||
let mut output = Vec::with_capacity(samples.len());
|
||||
let coeffs = &self.hilbert_coeffs;
|
||||
|
||||
for i in 0..samples.len() {
|
||||
// Linear FIR convolution — window is work[i..i+n].
|
||||
let window = &work[i..i + n];
|
||||
let mut q = 0.0f32;
|
||||
for k in 0..n {
|
||||
q += coeffs[k] * window[n - 1 - k];
|
||||
}
|
||||
|
||||
// In-phase component is the delayed input (group delay = half).
|
||||
let i_val = work[i + half];
|
||||
|
||||
// Instantaneous frequency via phase differentiation:
|
||||
// f = |arg(z[n] · conj(z[n-1]))| / (2π·Ts)
|
||||
let di = i_val * self.prev_i + q * self.prev_q;
|
||||
let dq = q * self.prev_i - i_val * self.prev_q;
|
||||
let freq = dq.atan2(di).abs() * self.inv_2pi_ts;
|
||||
|
||||
// Map frequency to luminance.
|
||||
let lum = ((freq - self.black_hz) * self.inv_range_hz).clamp(0.0, 1.0);
|
||||
output.push(lum);
|
||||
|
||||
self.prev_i = i_val;
|
||||
self.prev_q = q;
|
||||
}
|
||||
|
||||
// Save tail for next call.
|
||||
self.tail.copy_from_slice(&work[work_len - tail_len..]);
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
self.tail.fill(0.0);
|
||||
self.prev_i = 0.0;
|
||||
self.prev_q = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Design a Hilbert transform FIR filter (odd-length, type III).
|
||||
///
|
||||
/// The impulse response is: h[n] = 2/(πn) for odd n (relative to centre),
|
||||
/// 0 for even n, windowed with a Blackman window.
|
||||
fn design_hilbert_fir() -> [f32; HILBERT_TAPS] {
|
||||
let num_taps = HILBERT_TAPS;
|
||||
let mut coeffs = [0.0f32; HILBERT_TAPS];
|
||||
let m = (num_taps - 1) as f64;
|
||||
let mid = m / 2.0;
|
||||
|
||||
let mut i = 0;
|
||||
while i < num_taps {
|
||||
let n = i as f64 - mid;
|
||||
let ni = n.round() as i64;
|
||||
if ni != 0 && ni % 2 != 0 {
|
||||
// Hilbert kernel: 2/(π·n) for odd offsets.
|
||||
let h = 2.0 / (std::f64::consts::PI * n);
|
||||
// Blackman window.
|
||||
let w = 0.42 - 0.5 * (2.0 * std::f64::consts::PI * i as f64 / m).cos()
|
||||
+ 0.08 * (4.0 * std::f64::consts::PI * i as f64 / m).cos();
|
||||
coeffs[i] = (h * w) as f32;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
coeffs
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn discriminator_white_tone() {
|
||||
// Feed a pure 2300 Hz tone, expect luminance ≈ 1.0.
|
||||
let sr = 11025;
|
||||
let mut disc = FmDiscriminator::new(sr, 1900.0, 400.0);
|
||||
let n = 2000;
|
||||
let tone: Vec<f32> = (0..n)
|
||||
.map(|i| (2.0 * PI * 2300.0 * i as f32 / sr as f32).sin())
|
||||
.collect();
|
||||
let lum = disc.process(&tone);
|
||||
// Skip initial transient (Hilbert FIR settling).
|
||||
let tail = &lum[lum.len() / 2..];
|
||||
let avg: f32 = tail.iter().sum::<f32>() / tail.len() as f32;
|
||||
assert!(
|
||||
(avg - 1.0).abs() < 0.05,
|
||||
"expected ~1.0 for white tone, got {}",
|
||||
avg
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discriminator_black_tone() {
|
||||
// Feed a pure 1500 Hz tone, expect luminance ≈ 0.0.
|
||||
let sr = 11025;
|
||||
let mut disc = FmDiscriminator::new(sr, 1900.0, 400.0);
|
||||
let n = 2000;
|
||||
let tone: Vec<f32> = (0..n)
|
||||
.map(|i| (2.0 * PI * 1500.0 * i as f32 / sr as f32).sin())
|
||||
.collect();
|
||||
let lum = disc.process(&tone);
|
||||
let tail = &lum[lum.len() / 2..];
|
||||
let avg: f32 = tail.iter().sum::<f32>() / tail.len() as f32;
|
||||
assert!(avg < 0.05, "expected ~0.0 for black tone, got {}", avg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discriminator_center_tone() {
|
||||
// Feed 1900 Hz (center), expect luminance ≈ 0.5.
|
||||
let sr = 11025;
|
||||
let mut disc = FmDiscriminator::new(sr, 1900.0, 400.0);
|
||||
let n = 2000;
|
||||
let tone: Vec<f32> = (0..n)
|
||||
.map(|i| (2.0 * PI * 1900.0 * i as f32 / sr as f32).sin())
|
||||
.collect();
|
||||
let lum = disc.process(&tone);
|
||||
let tail = &lum[lum.len() / 2..];
|
||||
let avg: f32 = tail.iter().sum::<f32>() / tail.len() as f32;
|
||||
assert!(
|
||||
(avg - 0.5).abs() < 0.05,
|
||||
"expected ~0.5 for center tone, got {}",
|
||||
avg
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! Image buffer and PNG encoding for WEFAX decoded images.
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use tracing::{debug, warn};
|
||||
|
||||
/// Image assembler: accumulates greyscale lines and encodes to PNG.
|
||||
pub struct ImageAssembler {
|
||||
pixels_per_line: usize,
|
||||
lines: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl ImageAssembler {
|
||||
pub fn new(pixels_per_line: usize) -> Self {
|
||||
Self {
|
||||
pixels_per_line,
|
||||
lines: Vec::with_capacity(800),
|
||||
}
|
||||
}
|
||||
|
||||
/// Append a completed greyscale line.
|
||||
pub fn push_line(&mut self, line: Vec<u8>) {
|
||||
debug_assert_eq!(line.len(), self.pixels_per_line);
|
||||
self.lines.push(line);
|
||||
}
|
||||
|
||||
/// Number of lines accumulated so far.
|
||||
pub fn line_count(&self) -> u32 {
|
||||
self.lines.len() as u32
|
||||
}
|
||||
|
||||
/// Get the most recently added line (for progress events).
|
||||
pub fn last_line(&self) -> Option<&[u8]> {
|
||||
self.lines.last().map(|l| l.as_slice())
|
||||
}
|
||||
|
||||
/// Pearson correlation between `line` and the most recently pushed line.
|
||||
///
|
||||
/// Returns `None` if there is no previous line, the lengths don't match,
|
||||
/// or either line has near-zero variance (constant pixels — correlation
|
||||
/// is undefined, and flat regions shouldn't be scored as "noise").
|
||||
///
|
||||
/// For real WEFAX image content adjacent lines are typically highly
|
||||
/// correlated (r > 0.5). When the signal is lost and the slicer feeds
|
||||
/// on noise, r collapses toward 0. This mirrors fldigi's line-to-line
|
||||
/// correlation check for automatic stop.
|
||||
pub fn correlation_with_last(&self, line: &[u8]) -> Option<f32> {
|
||||
let prev = self.lines.last()?;
|
||||
if prev.len() != line.len() || line.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let n = line.len() as f32;
|
||||
let mean_a = prev.iter().map(|&v| v as f32).sum::<f32>() / n;
|
||||
let mean_b = line.iter().map(|&v| v as f32).sum::<f32>() / n;
|
||||
|
||||
let mut cov = 0.0f32;
|
||||
let mut var_a = 0.0f32;
|
||||
let mut var_b = 0.0f32;
|
||||
for (&a, &b) in prev.iter().zip(line.iter()) {
|
||||
let da = a as f32 - mean_a;
|
||||
let db = b as f32 - mean_b;
|
||||
cov += da * db;
|
||||
var_a += da * da;
|
||||
var_b += db * db;
|
||||
}
|
||||
|
||||
// Require some variance in both lines — flat regions are common in
|
||||
// real imagery (solid black/white) and shouldn't be penalised.
|
||||
const MIN_VAR: f32 = 32.0; // ~ stddev of 4 counts on 0..255 scale
|
||||
if var_a < MIN_VAR || var_b < MIN_VAR {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(cov / (var_a.sqrt() * var_b.sqrt()))
|
||||
}
|
||||
|
||||
/// Encode the accumulated image to an 8-bit greyscale PNG file.
|
||||
///
|
||||
/// Returns the full path to the saved file.
|
||||
pub fn save_png(&self, output_dir: &Path, freq_hz: u64, mode: &str) -> Result<PathBuf, String> {
|
||||
if self.lines.is_empty() {
|
||||
return Err("no image lines to save".into());
|
||||
}
|
||||
|
||||
// Detect row-length drift before handing bytes to the encoder.
|
||||
// png::Writer only validates the total byte count, so if some
|
||||
// rows were pushed at the wrong width the total could still
|
||||
// match and the decoded image would be silently skewed.
|
||||
let expected = self.pixels_per_line;
|
||||
let mut bad_rows: usize = 0;
|
||||
for (i, line) in self.lines.iter().enumerate() {
|
||||
if line.len() != expected {
|
||||
bad_rows += 1;
|
||||
if bad_rows <= 3 {
|
||||
warn!(
|
||||
row = i,
|
||||
got = line.len(),
|
||||
expected,
|
||||
"WEFAX: scan line has wrong width"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if bad_rows > 0 {
|
||||
return Err(format!(
|
||||
"{} scan line(s) have wrong width (expected {} px)",
|
||||
bad_rows, expected
|
||||
));
|
||||
}
|
||||
|
||||
std::fs::create_dir_all(output_dir).map_err(|e| format!("create output dir: {}", e))?;
|
||||
|
||||
let filename = generate_filename(freq_hz, mode);
|
||||
let path = output_dir.join(&filename);
|
||||
|
||||
// We already buffer the image rows into `img_data` below and
|
||||
// write them in a single call, so a BufWriter adds no value.
|
||||
// Using the bare `File` also lets us fsync explicitly below.
|
||||
let file = std::fs::File::create(&path)
|
||||
.map_err(|e| format!("create PNG file '{}': {}", path.display(), e))?;
|
||||
|
||||
let width = self.pixels_per_line as u32;
|
||||
let height = self.lines.len() as u32;
|
||||
|
||||
let mut encoder = png::Encoder::new(&file, width, height);
|
||||
encoder.set_color(png::ColorType::Grayscale);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
|
||||
let mut writer = encoder
|
||||
.write_header()
|
||||
.map_err(|e| format!("write PNG header: {}", e))?;
|
||||
|
||||
// Write all rows.
|
||||
let expected_bytes = (width as usize) * (height as usize);
|
||||
let mut img_data = Vec::with_capacity(expected_bytes);
|
||||
for line in &self.lines {
|
||||
img_data.extend_from_slice(line);
|
||||
}
|
||||
debug_assert_eq!(img_data.len(), expected_bytes);
|
||||
|
||||
writer.write_image_data(&img_data).map_err(|e| {
|
||||
format!(
|
||||
"write PNG data ({} bytes, {}x{}): {}",
|
||||
img_data.len(),
|
||||
width,
|
||||
height,
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
// Explicitly finish the writer (writes IEND). Relying on Drop
|
||||
// alone swallows any I/O error and can yield a truncated file.
|
||||
writer
|
||||
.finish()
|
||||
.map_err(|e| format!("finalize PNG: {}", e))?;
|
||||
// Flush the underlying file so the data is durably on disk by
|
||||
// the time we emit the WefaxEvent::Complete.
|
||||
(&file)
|
||||
.flush()
|
||||
.map_err(|e| format!("flush PNG file: {}", e))?;
|
||||
file.sync_all()
|
||||
.map_err(|e| format!("sync PNG file: {}", e))?;
|
||||
|
||||
let file_size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
|
||||
debug!(
|
||||
path = %path.display(),
|
||||
width,
|
||||
height,
|
||||
bytes = file_size,
|
||||
"WEFAX: saved PNG"
|
||||
);
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
self.lines.clear();
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_filename(freq_hz: u64, mode: &str) -> String {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default();
|
||||
let secs = now.as_secs();
|
||||
|
||||
// Convert to UTC datetime components manually (avoid chrono dependency).
|
||||
let (year, month, day, hour, min, sec) = unix_to_utc(secs);
|
||||
let freq_khz = freq_hz / 1000;
|
||||
|
||||
format!(
|
||||
"{:04}-{:02}-{:02}_{:02}-{:02}-{:02}-{}_kHz_{}.png",
|
||||
year, month, day, hour, min, sec, freq_khz, mode
|
||||
)
|
||||
}
|
||||
|
||||
/// Convert Unix timestamp to (year, month, day, hour, minute, second) in UTC.
|
||||
fn unix_to_utc(secs: u64) -> (u32, u32, u32, u32, u32, u32) {
|
||||
let s = secs;
|
||||
let sec = (s % 60) as u32;
|
||||
let min = ((s / 60) % 60) as u32;
|
||||
let hour = ((s / 3600) % 24) as u32;
|
||||
|
||||
let mut days = (s / 86400) as i64;
|
||||
// Days since 1970-01-01.
|
||||
let mut year = 1970u32;
|
||||
loop {
|
||||
let days_in_year = if is_leap(year) { 366 } else { 365 };
|
||||
if days < days_in_year {
|
||||
break;
|
||||
}
|
||||
days -= days_in_year;
|
||||
year += 1;
|
||||
}
|
||||
|
||||
let leap = is_leap(year);
|
||||
let month_days = [
|
||||
31,
|
||||
if leap { 29 } else { 28 },
|
||||
31,
|
||||
30,
|
||||
31,
|
||||
30,
|
||||
31,
|
||||
31,
|
||||
30,
|
||||
31,
|
||||
30,
|
||||
31,
|
||||
];
|
||||
|
||||
let mut month = 0u32;
|
||||
for (i, &md) in month_days.iter().enumerate() {
|
||||
if days < md as i64 {
|
||||
month = i as u32 + 1;
|
||||
break;
|
||||
}
|
||||
days -= md as i64;
|
||||
}
|
||||
let day = days as u32 + 1;
|
||||
|
||||
(year, month, day, hour, min, sec)
|
||||
}
|
||||
|
||||
fn is_leap(y: u32) -> bool {
|
||||
y.is_multiple_of(4) && (!y.is_multiple_of(100) || y.is_multiple_of(400))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn correlation_identifies_noise_vs_image() {
|
||||
let mut asm = ImageAssembler::new(256);
|
||||
|
||||
// No previous line.
|
||||
assert!(asm.correlation_with_last(&[0u8; 256]).is_none());
|
||||
|
||||
// Flat line, then a gradient: first call has no reference.
|
||||
let gradient: Vec<u8> = (0..256).map(|i| i as u8).collect();
|
||||
asm.push_line(gradient.clone());
|
||||
|
||||
// Nearly identical line — correlation ≈ 1.
|
||||
let near: Vec<u8> = (0..256).map(|i| i as u8).collect();
|
||||
let r = asm.correlation_with_last(&near).expect("r");
|
||||
assert!(r > 0.99, "identical lines should correlate: r={}", r);
|
||||
|
||||
// Pseudo-random noise vs gradient — correlation should be low.
|
||||
let noise: Vec<u8> = (0..256)
|
||||
.map(|i| ((i * 1103515245 + 12345) as u32 >> 8 & 0xff) as u8)
|
||||
.collect();
|
||||
let r = asm.correlation_with_last(&noise).expect("r");
|
||||
assert!(
|
||||
r.abs() < 0.3,
|
||||
"noise vs gradient should not correlate: r={}",
|
||||
r
|
||||
);
|
||||
|
||||
// Flat line returns None (no variance).
|
||||
assert!(asm.correlation_with_last(&[128u8; 256]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_assembler_line_count() {
|
||||
let mut asm = ImageAssembler::new(1809);
|
||||
assert_eq!(asm.line_count(), 0);
|
||||
asm.push_line(vec![128; 1809]);
|
||||
assert_eq!(asm.line_count(), 1);
|
||||
asm.push_line(vec![255; 1809]);
|
||||
assert_eq!(asm.line_count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_png_to_temp_dir() {
|
||||
let mut asm = ImageAssembler::new(100);
|
||||
for i in 0..50 {
|
||||
let val = (i * 255 / 49) as u8;
|
||||
asm.push_line(vec![val; 100]);
|
||||
}
|
||||
|
||||
let dir = std::env::temp_dir().join("trx-wefax-test");
|
||||
let result = asm.save_png(&dir, 7880000, "USB");
|
||||
assert!(result.is_ok(), "save_png failed: {:?}", result.err());
|
||||
let path = result.unwrap();
|
||||
assert!(path.exists());
|
||||
|
||||
// Read the file back and verify it decodes as a valid 8-bit
|
||||
// greyscale PNG of the expected size. This catches truncation
|
||||
// or IHDR-vs-IDAT mismatches that file-existence alone misses.
|
||||
let decoder = png::Decoder::new(std::fs::File::open(&path).unwrap());
|
||||
let mut reader = decoder.read_info().expect("PNG header invalid");
|
||||
let info = reader.info();
|
||||
assert_eq!(info.width, 100);
|
||||
assert_eq!(info.height, 50);
|
||||
assert_eq!(info.color_type, png::ColorType::Grayscale);
|
||||
assert_eq!(info.bit_depth, png::BitDepth::Eight);
|
||||
let mut buf = vec![0; reader.output_buffer_size()];
|
||||
reader.next_frame(&mut buf).expect("PNG data truncated");
|
||||
assert_eq!(buf.len(), 100 * 50);
|
||||
|
||||
// Clean up.
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_dir(&dir);
|
||||
}
|
||||
|
||||
/// Verify save_png survives realistic WEFAX dimensions (IOC 576 →
|
||||
/// 1809 px wide, 800+ lines tall) and that every byte round-trips.
|
||||
#[test]
|
||||
fn save_png_realistic_dimensions() {
|
||||
let ppl = crate::config::WefaxConfig::pixels_per_line(576) as usize;
|
||||
let mut asm = ImageAssembler::new(ppl);
|
||||
for y in 0..820u32 {
|
||||
let row: Vec<u8> = (0..ppl)
|
||||
.map(|x| ((x as u32 ^ y).wrapping_mul(17) & 0xff) as u8)
|
||||
.collect();
|
||||
asm.push_line(row);
|
||||
}
|
||||
let dir = std::env::temp_dir().join("trx-wefax-test-realistic");
|
||||
let path = asm.save_png(&dir, 7880000, "USB").expect("save_png");
|
||||
let bytes = std::fs::read(&path).expect("read back");
|
||||
assert!(bytes.starts_with(b"\x89PNG\r\n\x1a\n"), "missing PNG magic");
|
||||
// IEND chunk should be the last 12 bytes.
|
||||
assert_eq!(&bytes[bytes.len() - 8..bytes.len() - 4], b"IEND");
|
||||
|
||||
let decoder = png::Decoder::new(&bytes[..]);
|
||||
let mut reader = decoder.read_info().expect("decode header");
|
||||
let info = reader.info();
|
||||
assert_eq!(info.width, ppl as u32);
|
||||
assert_eq!(info.height, 820);
|
||||
let mut buf = vec![0; reader.output_buffer_size()];
|
||||
reader.next_frame(&mut buf).expect("decode data");
|
||||
assert_eq!(buf.len(), ppl * 820);
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_dir(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unix_to_utc_epoch() {
|
||||
let (y, m, d, h, mi, s) = unix_to_utc(0);
|
||||
assert_eq!((y, m, d, h, mi, s), (1970, 1, 1, 0, 0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unix_to_utc_known_date() {
|
||||
// 2026-03-28T14:30:00 UTC = 1774718600 (approximately)
|
||||
let (y, m, d, h, mi, _) = unix_to_utc(1775055000);
|
||||
assert_eq!(y, 2026);
|
||||
// Just verify reasonable values without asserting exact date.
|
||||
assert!(m >= 1 && m <= 12);
|
||||
assert!(d >= 1 && d <= 31);
|
||||
assert!(h < 24);
|
||||
assert!(mi < 60);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! WEFAX (Weather Facsimile) decoder.
|
||||
//!
|
||||
//! Pure Rust implementation supporting 60/90/120/240 LPM, IOC 288 and 576,
|
||||
//! with automatic APT tone detection and phase alignment.
|
||||
|
||||
pub mod config;
|
||||
pub mod decoder;
|
||||
pub mod demod;
|
||||
pub mod image;
|
||||
pub mod line_slicer;
|
||||
pub mod phase;
|
||||
pub mod resampler;
|
||||
pub mod tone_detect;
|
||||
|
||||
pub use config::WefaxConfig;
|
||||
pub use decoder::{WefaxDecoder, WefaxEvent};
|
||||
@@ -0,0 +1,148 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! Line slicer: pixel clock recovery and line buffer assembly.
|
||||
//!
|
||||
//! Once the phasing detector has established a line-start phase offset,
|
||||
//! the line slicer accumulates demodulated luminance samples and extracts
|
||||
//! complete image lines at the configured LPM rate.
|
||||
|
||||
use crate::config::WefaxConfig;
|
||||
|
||||
/// Line slicer for WEFAX image assembly.
|
||||
pub struct LineSlicer {
|
||||
/// Samples per line at the internal sample rate.
|
||||
samples_per_line: usize,
|
||||
/// Pixels per line (IOC × π).
|
||||
pixels_per_line: usize,
|
||||
/// Phase offset in samples from the phasing detector.
|
||||
phase_offset: usize,
|
||||
/// Accumulated luminance samples.
|
||||
buffer: Vec<f32>,
|
||||
/// Whether we have aligned to the phase offset yet.
|
||||
aligned: bool,
|
||||
}
|
||||
|
||||
impl LineSlicer {
|
||||
pub fn new(lpm: u16, ioc: u16, sample_rate: u32, phase_offset: usize) -> Self {
|
||||
let samples_per_line = WefaxConfig::samples_per_line(lpm, sample_rate);
|
||||
let pixels_per_line = WefaxConfig::pixels_per_line(ioc) as usize;
|
||||
|
||||
Self {
|
||||
samples_per_line,
|
||||
pixels_per_line,
|
||||
phase_offset,
|
||||
buffer: Vec::with_capacity(samples_per_line * 2),
|
||||
aligned: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed luminance samples and extract complete image lines.
|
||||
///
|
||||
/// Returns a vector of completed lines, each as a `Vec<u8>` of
|
||||
/// greyscale pixel values (0–255).
|
||||
pub fn process(&mut self, lum_samples: &[f32]) -> Vec<Vec<u8>> {
|
||||
self.buffer.extend_from_slice(lum_samples);
|
||||
let mut lines = Vec::new();
|
||||
|
||||
// On first call, skip samples to align to the phase offset.
|
||||
if !self.aligned {
|
||||
if self.buffer.len() < self.phase_offset {
|
||||
return lines;
|
||||
}
|
||||
self.buffer.drain(..self.phase_offset);
|
||||
self.aligned = true;
|
||||
}
|
||||
|
||||
// Extract complete lines (single drain at the end to avoid O(n²)).
|
||||
let mut offset = 0;
|
||||
while offset + self.samples_per_line <= self.buffer.len() {
|
||||
let line_samples = &self.buffer[offset..offset + self.samples_per_line];
|
||||
let pixels = self.resample_line(line_samples);
|
||||
lines.push(pixels);
|
||||
offset += self.samples_per_line;
|
||||
}
|
||||
if offset > 0 {
|
||||
self.buffer.drain(..offset);
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
pub fn pixels_per_line(&self) -> usize {
|
||||
self.pixels_per_line
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
self.buffer.clear();
|
||||
self.aligned = false;
|
||||
}
|
||||
|
||||
/// Resample a line's worth of luminance samples to the target pixel count
|
||||
/// using linear interpolation.
|
||||
fn resample_line(&self, samples: &[f32]) -> Vec<u8> {
|
||||
let n_samples = samples.len() as f32;
|
||||
let n_pixels = self.pixels_per_line;
|
||||
let mut pixels = Vec::with_capacity(n_pixels);
|
||||
|
||||
for px in 0..n_pixels {
|
||||
// Map pixel index to sample position.
|
||||
let pos = (px as f32 + 0.5) * n_samples / n_pixels as f32;
|
||||
let idx = pos.floor() as usize;
|
||||
let frac = pos - idx as f32;
|
||||
|
||||
let v = if idx + 1 < samples.len() {
|
||||
samples[idx] * (1.0 - frac) + samples[idx + 1] * frac
|
||||
} else if idx < samples.len() {
|
||||
samples[idx]
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
pixels.push((v * 255.0).clamp(0.0, 255.0) as u8);
|
||||
}
|
||||
|
||||
pixels
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn slicer_extracts_correct_line_count() {
|
||||
let lpm = 120;
|
||||
let ioc = 576;
|
||||
let sr = 11025;
|
||||
let spl = WefaxConfig::samples_per_line(lpm, sr);
|
||||
let ppl = WefaxConfig::pixels_per_line(ioc) as usize;
|
||||
|
||||
let mut slicer = LineSlicer::new(lpm, ioc, sr, 0);
|
||||
// Feed exactly 3 lines worth of white.
|
||||
let samples = vec![1.0f32; spl * 3];
|
||||
let lines = slicer.process(&samples);
|
||||
assert_eq!(lines.len(), 3);
|
||||
assert_eq!(lines[0].len(), ppl);
|
||||
// All pixels should be white (255).
|
||||
assert!(lines[0].iter().all(|&p| p == 255));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slicer_linear_interpolation() {
|
||||
let lpm = 120;
|
||||
let ioc = 576;
|
||||
let sr = 11025;
|
||||
let spl = WefaxConfig::samples_per_line(lpm, sr);
|
||||
|
||||
let mut slicer = LineSlicer::new(lpm, ioc, sr, 0);
|
||||
// Feed a linear ramp from 0.0 to 1.0.
|
||||
let samples: Vec<f32> = (0..spl).map(|i| i as f32 / spl as f32).collect();
|
||||
let lines = slicer.process(&samples);
|
||||
assert_eq!(lines.len(), 1);
|
||||
// First pixel should be near 0, last pixel near 255.
|
||||
assert!(lines[0][0] < 5);
|
||||
assert!(lines[0].last().copied().unwrap_or(0) > 250);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! Phasing signal detector and line-start alignment for WEFAX.
|
||||
//!
|
||||
//! During the phasing period, each line is >95% white (luminance ≈ 1.0) with
|
||||
//! a narrow black pulse (~5% of line width) marking the line-start position.
|
||||
//! This module detects the pulse position via cross-correlation against
|
||||
//! a synthetic phasing template, and averages over multiple lines to
|
||||
//! establish a stable phase offset.
|
||||
|
||||
use crate::config::WefaxConfig;
|
||||
|
||||
/// Minimum number of phasing lines needed to establish phase lock.
|
||||
const MIN_PHASING_LINES: usize = 10;
|
||||
|
||||
/// Maximum variance (in samples²) of pulse position for phase to be considered stable.
|
||||
const MAX_PHASE_VARIANCE: f32 = 16.0;
|
||||
|
||||
/// Fraction of line width occupied by the black pulse in phasing signal.
|
||||
const PULSE_WIDTH_FRACTION: f32 = 0.05;
|
||||
|
||||
/// Phasing signal detector.
|
||||
pub struct PhasingDetector {
|
||||
samples_per_line: usize,
|
||||
pulse_width: usize,
|
||||
/// Collected pulse positions from each phasing line.
|
||||
pub(crate) pulse_positions: Vec<usize>,
|
||||
/// Luminance sample accumulator for the current line.
|
||||
line_buffer: Vec<f32>,
|
||||
/// Established phase offset (samples from buffer start to line start).
|
||||
phase_offset: Option<usize>,
|
||||
}
|
||||
|
||||
impl PhasingDetector {
|
||||
pub fn new(lpm: u16, sample_rate: u32) -> Self {
|
||||
let samples_per_line = WefaxConfig::samples_per_line(lpm, sample_rate);
|
||||
let pulse_width = (samples_per_line as f32 * PULSE_WIDTH_FRACTION).round() as usize;
|
||||
|
||||
Self {
|
||||
samples_per_line,
|
||||
pulse_width,
|
||||
pulse_positions: Vec::new(),
|
||||
line_buffer: Vec::with_capacity(samples_per_line),
|
||||
phase_offset: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed luminance samples. Returns `Some(offset)` once phase is locked.
|
||||
pub fn process(&mut self, lum_samples: &[f32]) -> Option<usize> {
|
||||
if self.phase_offset.is_some() {
|
||||
return self.phase_offset;
|
||||
}
|
||||
|
||||
for &s in lum_samples {
|
||||
self.line_buffer.push(s);
|
||||
|
||||
if self.line_buffer.len() >= self.samples_per_line {
|
||||
self.analyze_phasing_line();
|
||||
self.line_buffer.clear();
|
||||
}
|
||||
}
|
||||
|
||||
self.phase_offset
|
||||
}
|
||||
|
||||
/// Return the established phase offset, if locked.
|
||||
pub fn offset(&self) -> Option<usize> {
|
||||
self.phase_offset
|
||||
}
|
||||
|
||||
/// Check if phasing is complete and offset is stable.
|
||||
pub fn is_locked(&self) -> bool {
|
||||
self.phase_offset.is_some()
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
self.pulse_positions.clear();
|
||||
self.line_buffer.clear();
|
||||
self.phase_offset = None;
|
||||
}
|
||||
|
||||
fn analyze_phasing_line(&mut self) {
|
||||
let line = &self.line_buffer;
|
||||
|
||||
// Verify this looks like a phasing line: >90% should be high luminance.
|
||||
let white_count = line.iter().filter(|&&v| v > 0.7).count();
|
||||
if white_count < line.len() * 85 / 100 {
|
||||
// Not a phasing line; reset accumulated positions.
|
||||
self.pulse_positions.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the black pulse position via minimum-energy sliding window.
|
||||
let pw = self.pulse_width.max(1);
|
||||
let mut min_energy = f32::MAX;
|
||||
let mut min_pos = 0;
|
||||
|
||||
// Running sum for efficiency.
|
||||
let mut sum: f32 = line[..pw].iter().sum();
|
||||
if sum < min_energy {
|
||||
min_energy = sum;
|
||||
min_pos = 0;
|
||||
}
|
||||
|
||||
for i in 1..=(line.len() - pw) {
|
||||
sum += line[i + pw - 1] - line[i - 1];
|
||||
if sum < min_energy {
|
||||
min_energy = sum;
|
||||
min_pos = i;
|
||||
}
|
||||
}
|
||||
|
||||
// The black pulse should be significantly darker than the average.
|
||||
let avg_pulse = min_energy / pw as f32;
|
||||
if avg_pulse > 0.3 {
|
||||
// Pulse not dark enough, skip this line.
|
||||
return;
|
||||
}
|
||||
|
||||
// Record pulse position (centre of the pulse window).
|
||||
self.pulse_positions.push(min_pos + pw / 2);
|
||||
|
||||
// Check if we have enough samples and the variance is low.
|
||||
if self.pulse_positions.len() >= MIN_PHASING_LINES {
|
||||
let mean = self.pulse_positions.iter().sum::<usize>() as f32
|
||||
/ self.pulse_positions.len() as f32;
|
||||
let variance = self
|
||||
.pulse_positions
|
||||
.iter()
|
||||
.map(|&p| {
|
||||
let d = p as f32 - mean;
|
||||
d * d
|
||||
})
|
||||
.sum::<f32>()
|
||||
/ self.pulse_positions.len() as f32;
|
||||
|
||||
if variance < MAX_PHASE_VARIANCE {
|
||||
self.phase_offset = Some(mean.round() as usize);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn detect_phasing_pulse() {
|
||||
let lpm = 120;
|
||||
let sr = 11025;
|
||||
let spl = WefaxConfig::samples_per_line(lpm, sr);
|
||||
let mut det = PhasingDetector::new(lpm, sr);
|
||||
|
||||
// Create 20 phasing lines with a black pulse at ~10% of line width.
|
||||
let pw = (spl as f32 * PULSE_WIDTH_FRACTION).round() as usize;
|
||||
let pulse_start = spl / 10;
|
||||
let pulse_center = pulse_start + pw / 2;
|
||||
|
||||
for line_idx in 0..20 {
|
||||
let mut line = vec![1.0f32; spl];
|
||||
for j in pulse_start..pulse_start + pw {
|
||||
if j < spl {
|
||||
line[j] = 0.0;
|
||||
}
|
||||
}
|
||||
let result = det.process(&line);
|
||||
if let Some(offset) = result {
|
||||
assert!(
|
||||
(offset as i32 - pulse_center as i32).unsigned_abs() <= 3,
|
||||
"phase offset {} too far from expected {} (line {})",
|
||||
offset,
|
||||
pulse_center,
|
||||
line_idx,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
panic!(
|
||||
"phasing should have locked after 20 lines (spl={}, pw={}, positions={:?})",
|
||||
spl, pw, det.pulse_positions
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! Polyphase rational resampler: 48000 Hz → 11025 Hz.
|
||||
//!
|
||||
//! Ratio: 11025/48000 = 147/640 (after GCD reduction).
|
||||
//! Uses a polyphase FIR filter bank to avoid computing the full upsampled
|
||||
//! signal, consistent with `docs/Optimization-Guidelines.md`.
|
||||
//!
|
||||
//! Block-based: builds a linear `[history | input]` work buffer so the inner
|
||||
//! FIR convolution loop uses straight indexing (no modular arithmetic) and
|
||||
//! benefits from auto-vectorisation.
|
||||
|
||||
/// Internal processing sample rate.
|
||||
pub const INTERNAL_RATE: u32 = 11025;
|
||||
|
||||
/// Default input sample rate.
|
||||
pub const DEFAULT_INPUT_RATE: u32 = 48000;
|
||||
|
||||
/// Polyphase rational resampler.
|
||||
pub struct Resampler {
|
||||
/// Interpolation factor (numerator of the ratio).
|
||||
up: usize,
|
||||
/// Decimation factor (denominator of the ratio).
|
||||
down: usize,
|
||||
/// Number of taps per polyphase sub-filter.
|
||||
taps_per_phase: usize,
|
||||
/// Polyphase filter bank: `up` sub-filters, each with `taps_per_phase` taps.
|
||||
bank: Vec<Vec<f32>>,
|
||||
/// Input history buffer (`taps_per_phase` samples from the previous block).
|
||||
history: Vec<f32>,
|
||||
/// Current phase accumulator (tracks position in the up-sampled domain).
|
||||
phase: usize,
|
||||
}
|
||||
|
||||
impl Resampler {
|
||||
/// Create a resampler from `input_rate` to [`INTERNAL_RATE`].
|
||||
pub fn new(input_rate: u32) -> Self {
|
||||
let g = gcd(INTERNAL_RATE as usize, input_rate as usize);
|
||||
let up = INTERNAL_RATE as usize / g;
|
||||
let down = input_rate as usize / g;
|
||||
|
||||
// Design a low-pass FIR prototype for the upsampled rate.
|
||||
// The upsampled rate is `input_rate * up`. The output is then
|
||||
// decimated by `down`. The anti-alias cutoff should be at
|
||||
// `min(input_rate, output_rate) / 2`, which in normalized terms
|
||||
// (relative to the upsampled rate) is `0.5 / max(up, down)`.
|
||||
// Use 0.45 instead of 0.5 for transition band headroom.
|
||||
let num_taps = up * 16 + 1; // ~16 taps per phase
|
||||
let cutoff = 0.5 / (up.max(down) as f64);
|
||||
let prototype = design_lowpass(num_taps, cutoff, up as f64);
|
||||
|
||||
// Split prototype into polyphase bank.
|
||||
let taps_per_phase = prototype.len().div_ceil(up);
|
||||
let mut bank = vec![vec![0.0f32; taps_per_phase]; up];
|
||||
for (i, &coeff) in prototype.iter().enumerate() {
|
||||
let phase = i % up;
|
||||
let tap = i / up;
|
||||
bank[phase][tap] = coeff;
|
||||
}
|
||||
|
||||
// Normalize: each output sample comes from one sub-filter convolved
|
||||
// with the input history. For unity DC gain, each sub-filter's sum
|
||||
// must equal 1.0.
|
||||
for sub in &mut bank {
|
||||
let sub_sum: f64 = sub.iter().map(|&c| c as f64).sum();
|
||||
if sub_sum.abs() > 1e-12 {
|
||||
let scale = (1.0 / sub_sum) as f32;
|
||||
for c in sub.iter_mut() {
|
||||
*c *= scale;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let history = vec![0.0f32; taps_per_phase];
|
||||
|
||||
Self {
|
||||
up,
|
||||
down,
|
||||
taps_per_phase,
|
||||
bank,
|
||||
history,
|
||||
phase: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Process a block of input samples, returning resampled output.
|
||||
///
|
||||
/// Uses a linear `[history | input]` work buffer so the inner FIR
|
||||
/// convolution runs on contiguous memory with plain indexing.
|
||||
#[allow(clippy::needless_range_loop)]
|
||||
pub fn process(&mut self, input: &[f32]) -> Vec<f32> {
|
||||
let tpp = self.taps_per_phase;
|
||||
let mut output = Vec::with_capacity(input.len() * self.up / self.down + 2);
|
||||
|
||||
// Contiguous work buffer: [previous history | new input].
|
||||
let mut work = Vec::with_capacity(tpp + input.len());
|
||||
work.extend_from_slice(&self.history);
|
||||
work.extend_from_slice(input);
|
||||
|
||||
for p in 0..input.len() {
|
||||
// Generate output samples for all phases that map to this input.
|
||||
while self.phase < self.up {
|
||||
let coeffs = &self.bank[self.phase];
|
||||
let mut acc = 0.0f32;
|
||||
// Newest sample is at work[p + tpp], oldest at work[p + 1].
|
||||
// coeffs[k] corresponds to the (k+1)-th newest sample.
|
||||
for k in 0..tpp {
|
||||
acc += coeffs[k] * work[p + tpp - k];
|
||||
}
|
||||
output.push(acc);
|
||||
self.phase += self.down;
|
||||
}
|
||||
self.phase -= self.up;
|
||||
}
|
||||
|
||||
// Save last `tpp` samples as history for next block.
|
||||
let work_len = work.len();
|
||||
self.history.copy_from_slice(&work[work_len - tpp..]);
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
/// Reset internal state (call on frequency change / decoder reset).
|
||||
pub fn reset(&mut self) {
|
||||
self.history.fill(0.0);
|
||||
self.phase = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Design a windowed-sinc low-pass FIR filter.
|
||||
#[allow(clippy::needless_range_loop)]
|
||||
fn design_lowpass(num_taps: usize, cutoff: f64, gain: f64) -> Vec<f32> {
|
||||
let mut coeffs = vec![0.0f32; num_taps];
|
||||
let m = num_taps as f64 - 1.0;
|
||||
let mid = m / 2.0;
|
||||
|
||||
for i in 0..num_taps {
|
||||
let n = i as f64 - mid;
|
||||
// Sinc function.
|
||||
let sinc = if n.abs() < 1e-12 {
|
||||
2.0 * std::f64::consts::PI * cutoff
|
||||
} else {
|
||||
(2.0 * std::f64::consts::PI * cutoff * n).sin() / n
|
||||
};
|
||||
// Blackman window.
|
||||
let w = 0.42 - 0.5 * (2.0 * std::f64::consts::PI * i as f64 / m).cos()
|
||||
+ 0.08 * (4.0 * std::f64::consts::PI * i as f64 / m).cos();
|
||||
coeffs[i] = (sinc * w * gain) as f32;
|
||||
}
|
||||
|
||||
coeffs
|
||||
}
|
||||
|
||||
fn gcd(mut a: usize, mut b: usize) -> usize {
|
||||
while b != 0 {
|
||||
let t = b;
|
||||
b = a % b;
|
||||
a = t;
|
||||
}
|
||||
a
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resampler_ratio_48k_to_11025() {
|
||||
let r = Resampler::new(48000);
|
||||
// Feed 48000 samples, should get ~11025 out.
|
||||
let input: Vec<f32> = vec![0.0; 48000];
|
||||
let output = r.clone_and_process(&input);
|
||||
// Allow ±2 samples tolerance for edge effects.
|
||||
assert!(
|
||||
(output.len() as i64 - 11025).unsigned_abs() <= 2,
|
||||
"expected ~11025 samples, got {}",
|
||||
output.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resampler_dc_passthrough() {
|
||||
let mut r = Resampler::new(48000);
|
||||
// DC signal should pass through with unity gain (after settling).
|
||||
let input: Vec<f32> = vec![1.0; 4800];
|
||||
let output = r.process(&input);
|
||||
// Check last quarter of output is close to 1.0.
|
||||
let tail = &output[output.len() * 3 / 4..];
|
||||
let avg: f32 = tail.iter().sum::<f32>() / tail.len() as f32;
|
||||
assert!((avg - 1.0).abs() < 0.02, "DC gain mismatch: avg = {}", avg);
|
||||
}
|
||||
|
||||
impl Resampler {
|
||||
fn clone_and_process(&self, input: &[f32]) -> Vec<f32> {
|
||||
let mut r = Self {
|
||||
up: self.up,
|
||||
down: self.down,
|
||||
taps_per_phase: self.taps_per_phase,
|
||||
bank: self.bank.clone(),
|
||||
history: self.history.clone(),
|
||||
phase: self.phase,
|
||||
};
|
||||
r.process(input)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! APT tone detector for WEFAX start/stop signals.
|
||||
//!
|
||||
//! Detects three APT signals by counting black↔white transitions in the
|
||||
//! **demodulated luminance** stream (0.0–1.0):
|
||||
//! - 300 transitions/s: Start signal for IOC 576
|
||||
//! - 675 transitions/s: Start signal for IOC 288
|
||||
//! - 450 transitions/s: Stop signal (end of transmission)
|
||||
//!
|
||||
//! This matches the fldigi approach: the APT "tones" are not audio-frequency
|
||||
//! tones but transition rates in the demodulated FM output.
|
||||
|
||||
use tracing::trace;
|
||||
|
||||
/// Detected APT tone type.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AptTone {
|
||||
/// Start tone for IOC 576 (300 transitions/s).
|
||||
Start576,
|
||||
/// Start tone for IOC 288 (675 transitions/s).
|
||||
Start288,
|
||||
/// Stop tone (450 transitions/s).
|
||||
Stop,
|
||||
}
|
||||
|
||||
impl AptTone {
|
||||
/// Return the IOC value associated with this tone, if it's a start tone.
|
||||
pub fn ioc(self) -> Option<u16> {
|
||||
match self {
|
||||
AptTone::Start576 => Some(576),
|
||||
AptTone::Start288 => Some(288),
|
||||
AptTone::Stop => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result from the tone detector for a single analysis window.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ToneDetectResult {
|
||||
/// Which tone was detected, if any.
|
||||
pub tone: Option<AptTone>,
|
||||
/// Duration in seconds the tone has been sustained.
|
||||
pub sustained_s: f32,
|
||||
}
|
||||
|
||||
/// Luminance threshold above which a sample is considered "high" (white).
|
||||
const HIGH_THRESHOLD: f32 = 0.84;
|
||||
/// Luminance threshold below which a sample is considered "low" (black).
|
||||
const LOW_THRESHOLD: f32 = 0.16;
|
||||
|
||||
/// Frequency tolerance for matching APT frequencies (Hz).
|
||||
const FREQ_TOLERANCE: u32 = 10;
|
||||
|
||||
/// APT transition-counting detector operating on demodulated luminance.
|
||||
///
|
||||
/// Counts low→high transitions in half-second windows and compares the
|
||||
/// resulting frequency against the three APT target frequencies.
|
||||
pub struct ToneDetector {
|
||||
sample_rate: u32,
|
||||
/// Analysis window size in samples (~0.5 s).
|
||||
window_size: usize,
|
||||
/// Number of samples accumulated in the current window.
|
||||
sample_count: usize,
|
||||
/// Whether the signal is currently in the "high" state.
|
||||
is_high: bool,
|
||||
/// Number of low→high transitions in the current window.
|
||||
transitions: u32,
|
||||
/// Currently sustained tone and duration counter.
|
||||
current_tone: Option<AptTone>,
|
||||
sustained_windows: u32,
|
||||
/// Minimum number of consecutive matching windows before confirming.
|
||||
min_sustain_windows: u32,
|
||||
}
|
||||
|
||||
impl ToneDetector {
|
||||
pub fn new(sample_rate: u32) -> Self {
|
||||
let window_size = (sample_rate / 2) as usize; // ~0.5 s window
|
||||
let min_sustain_s = 1.0; // fldigi uses 2 consecutive half-second windows
|
||||
let window_duration_s = window_size as f32 / sample_rate as f32;
|
||||
let min_sustain_windows = (min_sustain_s / window_duration_s).ceil() as u32;
|
||||
|
||||
Self {
|
||||
sample_rate,
|
||||
window_size,
|
||||
sample_count: 0,
|
||||
is_high: false,
|
||||
transitions: 0,
|
||||
current_tone: None,
|
||||
sustained_windows: 0,
|
||||
min_sustain_windows,
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed **demodulated luminance** samples (0.0 = black, 1.0 = white).
|
||||
///
|
||||
/// Returns detection results at the end of each analysis window.
|
||||
pub fn process(&mut self, luminance: &[f32]) -> Vec<ToneDetectResult> {
|
||||
let mut results = Vec::new();
|
||||
for &s in luminance {
|
||||
// Track low→high transitions with hysteresis.
|
||||
if s > HIGH_THRESHOLD && !self.is_high {
|
||||
self.is_high = true;
|
||||
self.transitions += 1;
|
||||
} else if s < LOW_THRESHOLD && self.is_high {
|
||||
self.is_high = false;
|
||||
}
|
||||
|
||||
self.sample_count += 1;
|
||||
|
||||
if self.sample_count >= self.window_size {
|
||||
results.push(self.analyze_window());
|
||||
self.sample_count = 0;
|
||||
self.transitions = 0;
|
||||
}
|
||||
}
|
||||
results
|
||||
}
|
||||
|
||||
/// Check if a tone has been confirmed (sustained for the minimum duration).
|
||||
pub fn confirmed_tone(&self) -> Option<AptTone> {
|
||||
if self.sustained_windows >= self.min_sustain_windows {
|
||||
self.current_tone
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
self.sample_count = 0;
|
||||
self.transitions = 0;
|
||||
self.is_high = false;
|
||||
self.current_tone = None;
|
||||
self.sustained_windows = 0;
|
||||
}
|
||||
|
||||
fn analyze_window(&mut self) -> ToneDetectResult {
|
||||
// Compute transition frequency: transitions per second.
|
||||
let freq = self.transitions * self.sample_rate / self.sample_count.max(1) as u32;
|
||||
|
||||
let detected = classify_freq(freq);
|
||||
|
||||
if detected.is_some() || self.transitions > 50 {
|
||||
trace!(
|
||||
transitions = self.transitions,
|
||||
freq_hz = freq,
|
||||
detected = ?detected,
|
||||
sustained = self.sustained_windows,
|
||||
"APT tone analysis"
|
||||
);
|
||||
}
|
||||
|
||||
// Update sustained detection tracking.
|
||||
if detected == self.current_tone && detected.is_some() {
|
||||
self.sustained_windows += 1;
|
||||
} else {
|
||||
self.current_tone = detected;
|
||||
self.sustained_windows = if detected.is_some() { 1 } else { 0 };
|
||||
}
|
||||
|
||||
ToneDetectResult {
|
||||
tone: self.confirmed_tone(),
|
||||
sustained_s: self.sustained_windows as f32 * self.window_size as f32
|
||||
/ self.sample_rate as f32,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify a measured transition frequency into an APT tone.
|
||||
fn classify_freq(freq: u32) -> Option<AptTone> {
|
||||
if freq.abs_diff(300) <= FREQ_TOLERANCE {
|
||||
Some(AptTone::Start576)
|
||||
} else if freq.abs_diff(675) <= FREQ_TOLERANCE {
|
||||
Some(AptTone::Start288)
|
||||
} else if freq.abs_diff(450) <= FREQ_TOLERANCE {
|
||||
Some(AptTone::Stop)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::f32::consts::PI;
|
||||
|
||||
/// Generate a luminance signal that alternates between black and white
|
||||
/// at the given transition frequency (transitions per second).
|
||||
fn generate_apt_signal(trans_freq: f32, sample_rate: u32, duration_s: f32) -> Vec<f32> {
|
||||
let n = (sample_rate as f32 * duration_s) as usize;
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
// Square wave at trans_freq Hz: above 0 → white, below 0 → black.
|
||||
let phase = (2.0 * PI * trans_freq * i as f32 / sample_rate as f32).sin();
|
||||
if phase >= 0.0 {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_start_576_tone() {
|
||||
let sr = 11025;
|
||||
let mut det = ToneDetector::new(sr);
|
||||
let signal = generate_apt_signal(300.0, sr, 3.0);
|
||||
let results = det.process(&signal);
|
||||
let confirmed = results.iter().any(|r| r.tone == Some(AptTone::Start576));
|
||||
assert!(confirmed, "should detect 300 Hz APT start for IOC 576");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_start_288_tone() {
|
||||
let sr = 11025;
|
||||
let mut det = ToneDetector::new(sr);
|
||||
let signal = generate_apt_signal(675.0, sr, 3.0);
|
||||
let results = det.process(&signal);
|
||||
let confirmed = results.iter().any(|r| r.tone == Some(AptTone::Start288));
|
||||
assert!(confirmed, "should detect 675 Hz APT start for IOC 288");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_stop_tone() {
|
||||
let sr = 11025;
|
||||
let mut det = ToneDetector::new(sr);
|
||||
let signal = generate_apt_signal(450.0, sr, 3.0);
|
||||
let results = det.process(&signal);
|
||||
let confirmed = results.iter().any(|r| r.tone == Some(AptTone::Stop));
|
||||
assert!(confirmed, "should detect 450 Hz APT stop tone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_false_detect_on_silence() {
|
||||
let sr = 11025;
|
||||
let mut det = ToneDetector::new(sr);
|
||||
let silence = vec![0.5f32; sr as usize * 3]; // mid-grey, no transitions
|
||||
let results = det.process(&silence);
|
||||
assert!(
|
||||
results.iter().all(|r| r.tone.is_none()),
|
||||
"should not detect any tone on constant signal"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_false_detect_on_image_data() {
|
||||
let sr = 11025;
|
||||
let mut det = ToneDetector::new(sr);
|
||||
// Simulate random-ish image data (varying luminance, no consistent frequency).
|
||||
let n = sr as usize * 3;
|
||||
let signal: Vec<f32> = (0..n)
|
||||
.map(|i| {
|
||||
// Mix of frequencies that don't match any APT tone.
|
||||
let t = i as f32 / sr as f32;
|
||||
(0.5 + 0.3 * (2.0 * PI * 137.0 * t).sin() + 0.2 * (2.0 * PI * 523.0 * t).sin())
|
||||
.clamp(0.0, 1.0)
|
||||
})
|
||||
.collect();
|
||||
let results = det.process(&signal);
|
||||
assert!(
|
||||
results.iter().all(|r| r.tone.is_none()),
|
||||
"should not detect APT tone in random image data"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
[package]
|
||||
name = "trx-wspr"
|
||||
version.workspace = true
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
@@ -0,0 +1,510 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
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 = 2.0;
|
||||
const FINE_SEARCH_STEP_HZ: f32 = 0.25;
|
||||
|
||||
// Timing offset search: search ±2s in 0.5s steps (6000 samples at 12 kHz)
|
||||
const DT_SEARCH_RANGE_SAMPLES: isize = 2 * WSPR_SAMPLE_RATE as isize;
|
||||
const DT_SEARCH_STEP_SAMPLES: isize = (WSPR_SAMPLE_RATE as isize) / 2;
|
||||
|
||||
// Number of top frequency candidates to try full decode on
|
||||
const MAX_FREQ_CANDIDATES: usize = 8;
|
||||
|
||||
// Minimum normalized sync correlation score to attempt decode.
|
||||
// The reference wsprd uses minsync1=0.10 but applies additional filtering
|
||||
// downstream. A higher threshold here prevents noise from reaching the Fano
|
||||
// decoder and producing false positives.
|
||||
const MIN_SYNC_SCORE: f32 = 0.20;
|
||||
|
||||
// Soft-symbol normalization factor (reference wsprd: symfac=50)
|
||||
const SYMFAC: f32 = 50.0;
|
||||
|
||||
/// WSPR sync vector (162 bits). symbol = sync[i] + 2*data[i].
|
||||
/// The LSB of each received symbol should match this pattern.
|
||||
#[rustfmt::skip]
|
||||
pub(crate) const SYNC_VECTOR: [u8; 162] = [
|
||||
1,1,0,0,0,0,0,0,1,0,0,0,1,1,1,0,0,0,1,0,0,1,0,1,1,1,1,0,0,0,
|
||||
0,0,0,0,1,0,0,1,0,1,0,0,0,0,0,0,1,0,1,1,0,0,1,1,0,1,0,0,0,1,
|
||||
1,0,1,0,0,0,0,1,1,0,1,0,1,0,1,0,1,0,0,1,0,0,1,0,1,1,0,0,0,1,
|
||||
1,0,1,0,1,0,0,0,1,0,0,0,0,0,1,0,0,1,0,0,1,1,1,0,1,1,0,0,1,1,
|
||||
0,1,0,0,0,1,1,1,0,0,0,0,0,1,0,1,0,0,1,1,0,0,0,0,0,0,0,1,1,0,
|
||||
1,0,1,1,0,0,0,1,1,0,0,1,
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WsprDecodeResult {
|
||||
pub message: String,
|
||||
pub snr_db: f32,
|
||||
pub dt_s: f32,
|
||||
pub freq_hz: f32,
|
||||
}
|
||||
|
||||
// Minimum estimated SNR (dB) to attempt decode. WSPR's theoretical decode
|
||||
// limit is around -28 dB in 2500 Hz bandwidth, but the per-tone SNR estimate
|
||||
// computed here uses a narrower noise reference and reads higher. Setting
|
||||
// -20 dB is conservative enough to pass all real signals while rejecting
|
||||
// pure-noise candidates where the Fano decoder might otherwise hallucinate.
|
||||
const MIN_SNR_DB: f32 = -20.0;
|
||||
|
||||
pub struct WsprDecoder {
|
||||
min_rms: f32,
|
||||
}
|
||||
|
||||
struct DemodOutput {
|
||||
soft_symbols: [u8; WSPR_SYMBOL_COUNT],
|
||||
snr_db: f32,
|
||||
}
|
||||
|
||||
impl WsprDecoder {
|
||||
pub fn new() -> Result<Self, String> {
|
||||
Ok(Self { min_rms: 0.005 })
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
// Collect top frequency candidates across timing offsets
|
||||
let mut candidates: Vec<(f32, isize, f32)> = Vec::new(); // (freq, dt_samples, score)
|
||||
|
||||
let mut dt = -DT_SEARCH_RANGE_SAMPLES;
|
||||
while dt <= DT_SEARCH_RANGE_SAMPLES {
|
||||
let start = EXPECTED_SIGNAL_START_SAMPLES as isize + dt;
|
||||
if start < 0 || (start as usize) + WSPR_SIGNAL_SAMPLES > samples.len() {
|
||||
dt += DT_SEARCH_STEP_SAMPLES;
|
||||
continue;
|
||||
}
|
||||
let signal = &samples[start as usize..start as usize + WSPR_SIGNAL_SAMPLES];
|
||||
|
||||
// Coarse frequency search using sync vector correlation
|
||||
let mut freq_scores: Vec<(f32, f32)> = Vec::new();
|
||||
let mut freq = BASE_SEARCH_MIN_HZ;
|
||||
while freq <= BASE_SEARCH_MAX_HZ {
|
||||
let score = sync_correlation_score(signal, freq);
|
||||
freq_scores.push((freq, score));
|
||||
freq += BASE_SEARCH_STEP_HZ;
|
||||
}
|
||||
|
||||
// Keep top candidates from coarse search
|
||||
freq_scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||
for &(coarse_freq, _) in freq_scores.iter().take(3) {
|
||||
// Fine-tune frequency around each coarse candidate
|
||||
let mut best_fine_freq = coarse_freq;
|
||||
let mut best_fine_score = f32::MIN;
|
||||
let mut fine_freq = coarse_freq - BASE_SEARCH_STEP_HZ;
|
||||
while fine_freq <= coarse_freq + BASE_SEARCH_STEP_HZ {
|
||||
let score = sync_correlation_score(signal, fine_freq);
|
||||
if score > best_fine_score {
|
||||
best_fine_score = score;
|
||||
best_fine_freq = fine_freq;
|
||||
}
|
||||
fine_freq += FINE_SEARCH_STEP_HZ;
|
||||
}
|
||||
candidates.push((best_fine_freq, dt, best_fine_score));
|
||||
}
|
||||
dt += DT_SEARCH_STEP_SAMPLES;
|
||||
}
|
||||
|
||||
// Sort candidates by score (best first) and try to decode each
|
||||
candidates.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal));
|
||||
|
||||
let mut results = Vec::new();
|
||||
let mut seen_messages = std::collections::HashSet::new();
|
||||
// Track (freq, dt) of successful decodes to skip near-duplicates
|
||||
let mut decoded_positions: Vec<(f32, isize)> = Vec::new();
|
||||
|
||||
for &(freq, dt_samples, _score) in candidates.iter().take(MAX_FREQ_CANDIDATES) {
|
||||
// Skip candidates too close in (freq, dt) to an already-decoded signal
|
||||
let dominated = decoded_positions.iter().any(|&(df, ddt)| {
|
||||
(freq - df).abs() < 4.0 * TONE_SPACING_HZ
|
||||
&& (dt_samples - ddt).unsigned_abs() < DT_SEARCH_STEP_SAMPLES as usize
|
||||
});
|
||||
if dominated {
|
||||
continue;
|
||||
}
|
||||
let start = (EXPECTED_SIGNAL_START_SAMPLES as isize + dt_samples) as usize;
|
||||
let signal = &samples[start..start + WSPR_SIGNAL_SAMPLES];
|
||||
|
||||
// Use normalized sync score for threshold check
|
||||
let norm_score = sync_correlation_score_normalized(signal, freq);
|
||||
if norm_score < MIN_SYNC_SCORE {
|
||||
continue;
|
||||
}
|
||||
|
||||
let demod = demodulate_soft_symbols(signal, freq);
|
||||
|
||||
// Reject candidates where estimated SNR is too low — the Fano
|
||||
// decoder can converge on noise-only input after normalization.
|
||||
if demod.snr_db < MIN_SNR_DB {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(decoded) = protocol::decode_symbols(&demod.soft_symbols) {
|
||||
if seen_messages.insert(decoded.message.clone()) {
|
||||
let dt_s = dt_samples as f32 / WSPR_SAMPLE_RATE as f32;
|
||||
results.push(WsprDecodeResult {
|
||||
message: decoded.message,
|
||||
snr_db: demod.snr_db,
|
||||
dt_s,
|
||||
freq_hz: freq,
|
||||
});
|
||||
decoded_positions.push((freq, dt_samples));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
}
|
||||
|
||||
/// Score a candidate base frequency by correlating detected tone amplitudes
|
||||
/// with the known WSPR sync vector. Uses amplitude (sqrt of power) and
|
||||
/// normalizes by total power, matching the reference wsprd implementation.
|
||||
/// Higher score = better match. Range approximately [0.0, 1.0].
|
||||
fn sync_correlation_score(signal: &[f32], base_hz: f32) -> f32 {
|
||||
let nsyms = WSPR_SYMBOL_COUNT.min(signal.len() / WSPR_SYMBOL_SAMPLES);
|
||||
let mut ss = 0.0_f32;
|
||||
let sr = WSPR_SAMPLE_RATE as f32;
|
||||
|
||||
for (sym, &sync_bit) in SYNC_VECTOR.iter().enumerate().take(nsyms) {
|
||||
let off = sym * WSPR_SYMBOL_SAMPLES;
|
||||
let frame = &signal[off..off + WSPR_SYMBOL_SAMPLES];
|
||||
|
||||
// Compute amplitude (sqrt of power) at each of the 4 FSK tones
|
||||
let p0 = goertzel_power(frame, base_hz, sr).sqrt();
|
||||
let p1 = goertzel_power(frame, base_hz + TONE_SPACING_HZ, sr).sqrt();
|
||||
let p2 = goertzel_power(frame, base_hz + 2.0 * TONE_SPACING_HZ, sr).sqrt();
|
||||
let p3 = goertzel_power(frame, base_hz + 3.0 * TONE_SPACING_HZ, sr).sqrt();
|
||||
|
||||
// Correlate with sync vector: (p1+p3)-(p0+p2) weighted by (2*sync-1)
|
||||
let cmet = (p1 + p3) - (p0 + p2);
|
||||
if sync_bit == 1 {
|
||||
ss += cmet;
|
||||
} else {
|
||||
ss -= cmet;
|
||||
}
|
||||
}
|
||||
|
||||
// Raw (unnormalized) score for candidate ranking. At frequencies with no
|
||||
// signal, amplitude differences are near zero so raw score is naturally low.
|
||||
// Normalized threshold check is applied separately in decode_slot.
|
||||
ss
|
||||
}
|
||||
|
||||
/// Compute the normalized sync score (ss/totp) for threshold comparison.
|
||||
fn sync_correlation_score_normalized(signal: &[f32], base_hz: f32) -> f32 {
|
||||
let nsyms = WSPR_SYMBOL_COUNT.min(signal.len() / WSPR_SYMBOL_SAMPLES);
|
||||
let mut ss = 0.0_f32;
|
||||
let mut totp = 0.0_f32;
|
||||
let sr = WSPR_SAMPLE_RATE as f32;
|
||||
|
||||
for (sym, &sync_bit) in SYNC_VECTOR.iter().enumerate().take(nsyms) {
|
||||
let off = sym * WSPR_SYMBOL_SAMPLES;
|
||||
let frame = &signal[off..off + WSPR_SYMBOL_SAMPLES];
|
||||
|
||||
let p0 = goertzel_power(frame, base_hz, sr).sqrt();
|
||||
let p1 = goertzel_power(frame, base_hz + TONE_SPACING_HZ, sr).sqrt();
|
||||
let p2 = goertzel_power(frame, base_hz + 2.0 * TONE_SPACING_HZ, sr).sqrt();
|
||||
let p3 = goertzel_power(frame, base_hz + 3.0 * TONE_SPACING_HZ, sr).sqrt();
|
||||
|
||||
let cmet = (p1 + p3) - (p0 + p2);
|
||||
if sync_bit == 1 {
|
||||
ss += cmet;
|
||||
} else {
|
||||
ss -= cmet;
|
||||
}
|
||||
totp += p0 + p1 + p2 + p3;
|
||||
}
|
||||
|
||||
if totp > 0.0 {
|
||||
ss / totp
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Produce soft-decision symbols from a signal slice.
|
||||
///
|
||||
/// Each soft symbol is an unsigned byte (0-255) where 128 = no confidence,
|
||||
/// values above 128 mean data bit is likely 1, below 128 means likely 0.
|
||||
///
|
||||
/// This matches the reference wsprd `sync_and_demodulate` mode=2 output.
|
||||
fn demodulate_soft_symbols(signal: &[f32], base_hz: f32) -> DemodOutput {
|
||||
let sr = WSPR_SAMPLE_RATE as f32;
|
||||
let mut fsymb = [0.0_f32; 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];
|
||||
|
||||
// Compute amplitude (sqrt of power) at each tone — matches reference
|
||||
let p0 = goertzel_power(frame, base_hz, sr).sqrt();
|
||||
let p1 = goertzel_power(frame, base_hz + TONE_SPACING_HZ, sr).sqrt();
|
||||
let p2 = goertzel_power(frame, base_hz + 2.0 * TONE_SPACING_HZ, sr).sqrt();
|
||||
let p3 = goertzel_power(frame, base_hz + 3.0 * TONE_SPACING_HZ, sr).sqrt();
|
||||
|
||||
// Soft metric for the data bit:
|
||||
// sync=1 → data bit selects tone 1 (data=0) vs tone 3 (data=1)
|
||||
// sync=0 → data bit selects tone 0 (data=0) vs tone 2 (data=1)
|
||||
// Positive fsymb means data_bit=1 is more likely.
|
||||
if SYNC_VECTOR[sym] == 1 {
|
||||
fsymb[sym] = p3 - p1;
|
||||
} else {
|
||||
fsymb[sym] = p2 - p0;
|
||||
}
|
||||
|
||||
// SNR estimation: signal = best tone power, noise = out-of-band
|
||||
let best_amp = p0.max(p1).max(p2).max(p3);
|
||||
signal_sum += best_amp * best_amp;
|
||||
|
||||
let noise_a = goertzel_power(frame, base_hz - 8.0 * TONE_SPACING_HZ, sr);
|
||||
let noise_b = goertzel_power(frame, base_hz + 12.0 * TONE_SPACING_HZ, sr);
|
||||
noise_sum += (noise_a + noise_b) * 0.5;
|
||||
}
|
||||
|
||||
// Normalize: zero-mean, scale by symfac/stddev, clip to [-128,127], bias to [0,255]
|
||||
let n = WSPR_SYMBOL_COUNT as f32;
|
||||
let mean = fsymb.iter().sum::<f32>() / n;
|
||||
let var = fsymb.iter().map(|&x| (x - mean) * (x - mean)).sum::<f32>() / n;
|
||||
let fac = var.sqrt().max(1e-12);
|
||||
|
||||
let mut soft_symbols = [128u8; WSPR_SYMBOL_COUNT];
|
||||
for i in 0..WSPR_SYMBOL_COUNT {
|
||||
let v = SYMFAC * fsymb[i] / fac;
|
||||
let v = v.clamp(-128.0, 127.0);
|
||||
soft_symbols[i] = (v + 128.0) as u8;
|
||||
}
|
||||
|
||||
// SNR estimate
|
||||
let signal_avg = signal_sum / n;
|
||||
let noise_avg = (noise_sum / n).max(1e-12);
|
||||
let snr_db = 10.0 * (signal_avg / noise_avg).max(1e-12).log10();
|
||||
|
||||
DemodOutput {
|
||||
soft_symbols,
|
||||
snr_db,
|
||||
}
|
||||
}
|
||||
|
||||
/// Goertzel algorithm: compute power at a specific frequency in a windowed frame.
|
||||
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, sync_tone) in SYNC_VECTOR
|
||||
.iter()
|
||||
.copied()
|
||||
.enumerate()
|
||||
.take(WSPR_SYMBOL_COUNT)
|
||||
{
|
||||
let tone = sync_tone + 2 * ((sym % 2) as u8);
|
||||
let freq = base_hz + tone as f32 * 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 candidates = find_candidates(signal);
|
||||
assert!(!candidates.is_empty());
|
||||
let (estimated, _) = candidates[0];
|
||||
assert!(
|
||||
(estimated - base_hz).abs() <= 1.0,
|
||||
"estimated {estimated} Hz, expected {base_hz} Hz"
|
||||
);
|
||||
}
|
||||
|
||||
/// Helper: run the candidate search on a signal slice
|
||||
fn find_candidates(signal: &[f32]) -> Vec<(f32, f32)> {
|
||||
let mut freq_scores: Vec<(f32, f32)> = Vec::new();
|
||||
let mut freq = BASE_SEARCH_MIN_HZ;
|
||||
while freq <= BASE_SEARCH_MAX_HZ {
|
||||
let score = sync_correlation_score(signal, freq);
|
||||
freq_scores.push((freq, score));
|
||||
freq += BASE_SEARCH_STEP_HZ;
|
||||
}
|
||||
freq_scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||
|
||||
// Fine-tune top result
|
||||
if let Some(&(coarse_freq, _)) = freq_scores.first() {
|
||||
let mut best_fine_freq = coarse_freq;
|
||||
let mut best_fine_score = f32::MIN;
|
||||
let mut fine_freq = coarse_freq - BASE_SEARCH_STEP_HZ;
|
||||
while fine_freq <= coarse_freq + BASE_SEARCH_STEP_HZ {
|
||||
let score = sync_correlation_score(signal, fine_freq);
|
||||
if score > best_fine_score {
|
||||
best_fine_score = score;
|
||||
best_fine_freq = fine_freq;
|
||||
}
|
||||
fine_freq += FINE_SEARCH_STEP_HZ;
|
||||
}
|
||||
vec![(best_fine_freq, best_fine_score)]
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_correlation_prefers_correct_frequency() {
|
||||
let base_hz = 1500.0_f32;
|
||||
let wrong_hz = 1400.0_f32;
|
||||
|
||||
// Generate a synthetic WSPR-like signal using the sync vector
|
||||
let mut signal = vec![0.0_f32; WSPR_SIGNAL_SAMPLES];
|
||||
for (sym, sync_tone) in SYNC_VECTOR
|
||||
.iter()
|
||||
.copied()
|
||||
.enumerate()
|
||||
.take(WSPR_SYMBOL_COUNT)
|
||||
{
|
||||
let freq = base_hz + sync_tone as f32 * TONE_SPACING_HZ;
|
||||
let begin = sym * WSPR_SYMBOL_SAMPLES;
|
||||
for i in 0..WSPR_SYMBOL_SAMPLES {
|
||||
let t = i as f32 / WSPR_SAMPLE_RATE as f32;
|
||||
signal[begin + i] = (2.0 * std::f32::consts::PI * freq * t).sin() * 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
let correct_score = sync_correlation_score(&signal, base_hz);
|
||||
let wrong_score = sync_correlation_score(&signal, wrong_hz);
|
||||
assert!(
|
||||
correct_score > wrong_score,
|
||||
"correct={correct_score}, wrong={wrong_score}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noise_only_slot_produces_no_decodes() {
|
||||
// Deterministic pseudo-random noise via simple LCG
|
||||
let mut rng_state = 0x12345678u64;
|
||||
let mut next_f32 = || -> f32 {
|
||||
rng_state = rng_state.wrapping_mul(6364136223846793005).wrapping_add(1);
|
||||
((rng_state >> 33) as f32 / u32::MAX as f32) * 2.0 - 1.0
|
||||
};
|
||||
|
||||
let dec = WsprDecoder::new().expect("decoder");
|
||||
let slot: Vec<f32> = (0..dec.slot_samples()).map(|_| next_f32() * 0.05).collect();
|
||||
let results = dec.decode_slot(&slot, None).expect("decode");
|
||||
assert!(
|
||||
results.is_empty(),
|
||||
"noise-only slot should produce no decodes, got {}",
|
||||
results.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalized_sync_score_is_bounded() {
|
||||
let base_hz = 1500.0_f32;
|
||||
|
||||
// Generate a perfect synthetic WSPR signal
|
||||
let mut signal = vec![0.0_f32; WSPR_SIGNAL_SAMPLES];
|
||||
for (sym, sync_tone) in SYNC_VECTOR
|
||||
.iter()
|
||||
.copied()
|
||||
.enumerate()
|
||||
.take(WSPR_SYMBOL_COUNT)
|
||||
{
|
||||
// Use sync_tone as the only varying bit to maximize sync metric
|
||||
let freq = base_hz + sync_tone as f32 * TONE_SPACING_HZ;
|
||||
let begin = sym * WSPR_SYMBOL_SAMPLES;
|
||||
for i in 0..WSPR_SYMBOL_SAMPLES {
|
||||
let t = i as f32 / WSPR_SAMPLE_RATE as f32;
|
||||
signal[begin + i] = (2.0 * std::f32::consts::PI * freq * t).sin() * 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
let score = sync_correlation_score_normalized(&signal, base_hz);
|
||||
// Normalized score should be positive and bounded
|
||||
assert!(score > 0.0, "score should be positive: {score}");
|
||||
assert!(score <= 1.0, "score should be <= 1.0: {score}");
|
||||
// This synthetic signal only uses sync tones (no data tones), so the
|
||||
// normalized score is moderate (~0.18). A real WSPR signal occupies all
|
||||
// 4 tones and produces higher scores (>0.3).
|
||||
assert!(
|
||||
score > 0.10,
|
||||
"score {score} should be clearly above noise floor"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
mod decoder;
|
||||
mod protocol;
|
||||
|
||||
pub use decoder::{WsprDecodeResult, WsprDecoder};
|
||||
@@ -0,0 +1,578 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/// Decoded WSPR message payload.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WsprProtocolMessage {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
const POLY1: u32 = 0xF2D05351;
|
||||
const POLY2: u32 = 0xE4613C47;
|
||||
const NBITS: usize = 81; // 50 payload bits + 31 convolutional flush bits
|
||||
const NSYMS: usize = 162;
|
||||
|
||||
// Fano decoder parameters (matching reference wsprd)
|
||||
const FANO_DELTA: i32 = 60;
|
||||
const FANO_MAX_CYCLES_PER_BIT: usize = 10_000;
|
||||
const FANO_BIAS: f32 = 0.45;
|
||||
|
||||
/// Soft-decision metric table for the Fano decoder.
|
||||
/// Es/No = 6 dB log-likelihood ratio table from WSJT-X reference (metric_tables[2]).
|
||||
#[allow(clippy::approx_constant)]
|
||||
#[rustfmt::skip]
|
||||
const METRIC_TABLE: [f32; 256] = [
|
||||
0.9999, 0.9998, 0.9998, 0.9998, 0.9998, 0.9998, 0.9997, 0.9997,
|
||||
0.9997, 0.9997, 0.9997, 0.9996, 0.9996, 0.9996, 0.9995, 0.9995,
|
||||
0.9994, 0.9994, 0.9994, 0.9993, 0.9993, 0.9992, 0.9991, 0.9991,
|
||||
0.9990, 0.9989, 0.9988, 0.9988, 0.9988, 0.9986, 0.9985, 0.9984,
|
||||
0.9983, 0.9982, 0.9980, 0.9979, 0.9977, 0.9976, 0.9974, 0.9971,
|
||||
0.9969, 0.9968, 0.9965, 0.9962, 0.9960, 0.9957, 0.9953, 0.9950,
|
||||
0.9947, 0.9941, 0.9937, 0.9933, 0.9928, 0.9922, 0.9917, 0.9911,
|
||||
0.9904, 0.9897, 0.9890, 0.9882, 0.9874, 0.9863, 0.9855, 0.9843,
|
||||
0.9832, 0.9819, 0.9806, 0.9792, 0.9777, 0.9760, 0.9743, 0.9724,
|
||||
0.9704, 0.9683, 0.9659, 0.9634, 0.9609, 0.9581, 0.9550, 0.9516,
|
||||
0.9481, 0.9446, 0.9406, 0.9363, 0.9317, 0.9270, 0.9218, 0.9160,
|
||||
0.9103, 0.9038, 0.8972, 0.8898, 0.8822, 0.8739, 0.8647, 0.8554,
|
||||
0.8457, 0.8357, 0.8231, 0.8115, 0.7984, 0.7854, 0.7704, 0.7556,
|
||||
0.7391, 0.7210, 0.7038, 0.6840, 0.6633, 0.6408, 0.6174, 0.5939,
|
||||
0.5678, 0.5410, 0.5137, 0.4836, 0.4524, 0.4193, 0.3850, 0.3482,
|
||||
0.3132, 0.2733, 0.2315, 0.1891, 0.1435, 0.0980, 0.0493, 0.0000,
|
||||
-0.0510, -0.1052, -0.1593, -0.2177, -0.2759, -0.3374, -0.4005, -0.4599,
|
||||
-0.5266, -0.5935, -0.6626, -0.7328, -0.8051, -0.8757, -0.9498, -1.0271,
|
||||
-1.1019, -1.1816, -1.2642, -1.3459, -1.4295, -1.5077, -1.5958, -1.6818,
|
||||
-1.7647, -1.8548, -1.9387, -2.0295, -2.1152, -2.2154, -2.3011, -2.3904,
|
||||
-2.4820, -2.5786, -2.6730, -2.7652, -2.8616, -2.9546, -3.0526, -3.1445,
|
||||
-3.2445, -3.3416, -3.4357, -3.5325, -3.6324, -3.7313, -3.8225, -3.9209,
|
||||
-4.0248, -4.1278, -4.2261, -4.3193, -4.4220, -4.5262, -4.6214, -4.7242,
|
||||
-4.8234, -4.9245, -5.0298, -5.1250, -5.2232, -5.3267, -5.4332, -5.5342,
|
||||
-5.6431, -5.7270, -5.8401, -5.9350, -6.0407, -6.1418, -6.2363, -6.3384,
|
||||
-6.4536, -6.5429, -6.6582, -6.7433, -6.8438, -6.9478, -7.0789, -7.1894,
|
||||
-7.2714, -7.3815, -7.4810, -7.5575, -7.6852, -7.8071, -7.8580, -7.9724,
|
||||
-8.1000, -8.2207, -8.2867, -8.4017, -8.5287, -8.6347, -8.7082, -8.8319,
|
||||
-8.9448, -9.0355, -9.1885, -9.2095, -9.2863, -9.4186, -9.5064, -9.6386,
|
||||
-9.7207, -9.8286, -9.9453,-10.0701,-10.1735,-10.3001,-10.2858,-10.5427,
|
||||
-10.5982,-10.7361,-10.7042,-10.9212,-11.0097,-11.0469,-11.1155,-11.2812,
|
||||
-11.3472,-11.4988,-11.5327,-11.6692,-11.9376,-11.8606,-12.1372,-13.2539,
|
||||
];
|
||||
|
||||
/// Build the integer metric table for the soft-decision Fano decoder.
|
||||
///
|
||||
/// `mettab[0][rx]` = metric when expected coded bit is 0, received symbol is `rx`
|
||||
/// `mettab[1][rx]` = metric when expected coded bit is 1, received symbol is `rx`
|
||||
fn build_mettab() -> [[i32; 256]; 2] {
|
||||
let mut mettab = [[0i32; 256]; 2];
|
||||
for i in 0..256 {
|
||||
mettab[0][i] = (10.0 * (METRIC_TABLE[i] - FANO_BIAS)).round() as i32;
|
||||
mettab[1][i] = (10.0 * (METRIC_TABLE[255 - i] - FANO_BIAS)).round() as i32;
|
||||
}
|
||||
mettab
|
||||
}
|
||||
|
||||
/// Reverse the bits of an 8-bit value.
|
||||
fn rev8(mut b: u8) -> u8 {
|
||||
let mut r = 0u8;
|
||||
for _ in 0..8 {
|
||||
r = (r << 1) | (b & 1);
|
||||
b >>= 1;
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// Deinterleave soft symbols by permuting their order via bit-reversal of indices.
|
||||
///
|
||||
/// Unlike the old hard-decision version, this does NOT extract data bits — the
|
||||
/// soft values (0-255, centered at 128) are preserved as-is. The Fano decoder
|
||||
/// interprets them directly via the metric table.
|
||||
fn deinterleave(symbols: &[u8]) -> [u8; NSYMS] {
|
||||
let mut out = [128u8; NSYMS]; // default to "no confidence"
|
||||
let mut p = 0usize;
|
||||
for i in 0u16..=255 {
|
||||
let j = rev8(i as u8) as usize;
|
||||
if j < NSYMS {
|
||||
out[p] = if j < symbols.len() { symbols[j] } else { 128 };
|
||||
p += 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Compute the 2-bit convolutional encoder output for a given encoder state.
|
||||
///
|
||||
/// Returns a value 0-3 where:
|
||||
/// bit 1 (2's place) = parity(state & POLY1)
|
||||
/// bit 0 (1's place) = parity(state & POLY2)
|
||||
fn encode_sym(state: u32) -> u32 {
|
||||
let p1 = (state & POLY1).count_ones() & 1;
|
||||
let p2 = (state & POLY2).count_ones() & 1;
|
||||
(p1 << 1) | p2
|
||||
}
|
||||
|
||||
/// Result from the Fano decoder including quality metric.
|
||||
struct FanoResult {
|
||||
bits: [u8; NBITS],
|
||||
/// Cumulative path metric — higher values indicate higher confidence.
|
||||
metric: i64,
|
||||
}
|
||||
|
||||
/// Soft-decision Fano sequential decoder for K=32, rate-1/2 convolutional code.
|
||||
///
|
||||
/// Closely follows the reference implementation from WSJT-X (fano.c by Phil Karn, KA9Q).
|
||||
///
|
||||
/// Input: 162 deinterleaved soft-decision symbols (0-255, 128=no confidence).
|
||||
/// Symbols are read in pairs: `symbols[2k]` and `symbols[2k+1]` are the two
|
||||
/// coded bits for input bit k.
|
||||
///
|
||||
/// Output: decoded bits and cumulative path metric, or None on timeout.
|
||||
fn fano_decode(symbols: &[u8; NSYMS]) -> Option<FanoResult> {
|
||||
let mettab = build_mettab();
|
||||
let max_cycles = FANO_MAX_CYCLES_PER_BIT * NBITS;
|
||||
let tail_start = NBITS - 31; // position 50: first tail bit
|
||||
|
||||
// Precompute all 4 branch metrics for each bit position.
|
||||
// metrics[k][sym_pair] where sym_pair encodes (expected_bit0, expected_bit1):
|
||||
// 0 = (0,0), 1 = (0,1), 2 = (1,0), 3 = (1,1)
|
||||
let mut metrics = [[0i32; 4]; NBITS];
|
||||
for k in 0..NBITS {
|
||||
let s0 = symbols[2 * k] as usize;
|
||||
let s1 = symbols[2 * k + 1] as usize;
|
||||
metrics[k][0] = mettab[0][s0] + mettab[0][s1];
|
||||
metrics[k][1] = mettab[0][s0] + mettab[1][s1];
|
||||
metrics[k][2] = mettab[1][s0] + mettab[0][s1];
|
||||
metrics[k][3] = mettab[1][s0] + mettab[1][s1];
|
||||
}
|
||||
|
||||
// Per-node state
|
||||
let mut encstate = [0u32; NBITS + 1];
|
||||
let mut gamma = [0i64; NBITS + 1]; // cumulative path metric
|
||||
let mut tm = [[0i32; 2]; NBITS]; // sorted branch metrics [best, second]
|
||||
let mut branch_i = [0u8; NBITS]; // 0 = trying best branch, 1 = trying second
|
||||
|
||||
let mut pos: usize = 0;
|
||||
let mut t: i64 = 0; // threshold
|
||||
|
||||
// Initialize root node: compute and sort branch metrics
|
||||
let lsym = encode_sym(encstate[0]) as usize;
|
||||
let m0 = metrics[0][lsym];
|
||||
let m1 = metrics[0][3 ^ lsym];
|
||||
if m0 > m1 {
|
||||
tm[0] = [m0, m1];
|
||||
} else {
|
||||
tm[0] = [m1, m0];
|
||||
encstate[0] |= 1; // 1-branch is better; encode choice in LSB
|
||||
}
|
||||
branch_i[0] = 0;
|
||||
|
||||
for _cycle in 0..max_cycles {
|
||||
if pos >= NBITS {
|
||||
break;
|
||||
}
|
||||
|
||||
// Look forward: try current branch
|
||||
let ngamma = gamma[pos] + tm[pos][branch_i[pos] as usize] as i64;
|
||||
if ngamma >= t {
|
||||
// Acceptable — tighten threshold if this is a first visit
|
||||
if gamma[pos] < t + FANO_DELTA as i64 {
|
||||
while ngamma >= t + FANO_DELTA as i64 {
|
||||
t += FANO_DELTA as i64;
|
||||
}
|
||||
}
|
||||
|
||||
// Move forward
|
||||
gamma[pos + 1] = ngamma;
|
||||
encstate[pos + 1] = encstate[pos] << 1;
|
||||
pos += 1;
|
||||
|
||||
if pos >= NBITS {
|
||||
break; // Done!
|
||||
}
|
||||
|
||||
// Compute and sort metrics at the new position
|
||||
let lsym = encode_sym(encstate[pos]) as usize;
|
||||
if pos >= tail_start {
|
||||
// Tail must be all zeros — only consider 0-branch
|
||||
tm[pos] = [metrics[pos][lsym], i32::MIN];
|
||||
} else {
|
||||
let m0 = metrics[pos][lsym];
|
||||
let m1 = metrics[pos][3 ^ lsym];
|
||||
if m0 > m1 {
|
||||
tm[pos] = [m0, m1];
|
||||
} else {
|
||||
tm[pos] = [m1, m0];
|
||||
encstate[pos] |= 1; // mark 1-branch as better
|
||||
}
|
||||
}
|
||||
branch_i[pos] = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Threshold violated — look backward
|
||||
loop {
|
||||
if pos == 0 || gamma[pos - 1] < t {
|
||||
// Can't back up (at root, or parent's metric below threshold).
|
||||
// Relax threshold and reset to best branch at current position.
|
||||
t -= FANO_DELTA as i64;
|
||||
if branch_i[pos] != 0 {
|
||||
branch_i[pos] = 0;
|
||||
encstate[pos] ^= 1;
|
||||
}
|
||||
break;
|
||||
}
|
||||
// Back up to parent
|
||||
pos -= 1;
|
||||
if pos < tail_start && branch_i[pos] != 1 {
|
||||
// Try second branch at this position
|
||||
branch_i[pos] = 1;
|
||||
encstate[pos] ^= 1;
|
||||
break;
|
||||
}
|
||||
// Already tried both branches (or in tail) — keep backing up
|
||||
}
|
||||
}
|
||||
|
||||
if pos < NBITS {
|
||||
return None; // Timeout
|
||||
}
|
||||
|
||||
// Extract decoded bits from encoder states.
|
||||
// At each position k, the LSB of encstate[k] is the chosen input bit.
|
||||
let mut bits = [0u8; NBITS];
|
||||
for k in 0..NBITS {
|
||||
bits[k] = (encstate[k] & 1) as u8;
|
||||
}
|
||||
Some(FanoResult {
|
||||
bits,
|
||||
metric: gamma[NBITS],
|
||||
})
|
||||
}
|
||||
|
||||
/// Unpack 50 payload bits into a formatted WSPR message string.
|
||||
///
|
||||
/// Layout (MSB first):
|
||||
/// bits 0-27 — N1 (28 bits): callsign
|
||||
/// bits 28-42 — M1 (15 bits): Maidenhead grid
|
||||
/// bits 43-49 — P ( 7 bits): power code (dBm + 64)
|
||||
fn unpack_message(bits: &[u8; NBITS]) -> Option<String> {
|
||||
// Accumulate N1, M1, and power code from the bit array.
|
||||
let mut n1 = 0u32;
|
||||
for &b in &bits[..28] {
|
||||
n1 = (n1 << 1) | b as u32;
|
||||
}
|
||||
let mut m1 = 0u32;
|
||||
for &b in &bits[28..43] {
|
||||
m1 = (m1 << 1) | b as u32;
|
||||
}
|
||||
let mut power_code = 0u32;
|
||||
for &b in &bits[43..50] {
|
||||
power_code = (power_code << 1) | b as u32;
|
||||
}
|
||||
|
||||
// WSPR only permits specific power levels (dBm).
|
||||
const VALID_POWER: [i32; 19] = [
|
||||
0, 3, 7, 10, 13, 17, 20, 23, 27, 30, 33, 37, 40, 43, 47, 50, 53, 57, 60,
|
||||
];
|
||||
let power_dbm = power_code as i32;
|
||||
if !VALID_POWER.contains(&power_dbm) {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Decode callsign from N1.
|
||||
// N1 = ((c0*36 + c1)*10 + c2)*27^3 + c3*27^2 + c4*27 + c5
|
||||
// c0,c1 ∈ charset37; c2 ∈ '0'-'9'; c3,c4,c5 ∈ charset27
|
||||
const CS37: &[u8] = b" 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
const CS27: &[u8] = b" ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
|
||||
let mut n = n1;
|
||||
let i5 = (n % 27) as usize;
|
||||
n /= 27;
|
||||
let i4 = (n % 27) as usize;
|
||||
n /= 27;
|
||||
let i3 = (n % 27) as usize;
|
||||
n /= 27;
|
||||
let i2 = (n % 10) as usize;
|
||||
n /= 10;
|
||||
let i1 = (n % 36) as usize;
|
||||
n /= 36;
|
||||
let i0 = n as usize;
|
||||
|
||||
if i0 >= 37 || i1 >= 37 || i2 >= 10 || i3 >= 27 || i4 >= 27 || i5 >= 27 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let callsign = format!(
|
||||
"{}{}{}{}{}{}",
|
||||
CS37[i0] as char,
|
||||
CS37[i1] as char,
|
||||
(b'0' + i2 as u8) as char,
|
||||
CS27[i3] as char,
|
||||
CS27[i4] as char,
|
||||
CS27[i5] as char,
|
||||
)
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
// WSPR callsigns: after trimming, the digit (from position 2 of the
|
||||
// 6-char padded form) must appear at index 1 or 2. The callsign must
|
||||
// also contain at least one letter and be at least 3 characters long.
|
||||
if callsign.len() < 3 || !callsign.chars().any(|c| c.is_alphabetic()) {
|
||||
return None;
|
||||
}
|
||||
let has_digit_at_1_or_2 = callsign.chars().nth(1).is_some_and(|c| c.is_ascii_digit())
|
||||
|| callsign.chars().nth(2).is_some_and(|c| c.is_ascii_digit());
|
||||
if !has_digit_at_1_or_2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Decode Maidenhead grid from M1.
|
||||
// M1 = (179 - 10*loc1 - loc3)*180 + 10*loc2 + loc4
|
||||
// loc1,loc2 ∈ 0-17 (A-R); loc3,loc4 ∈ 0-9
|
||||
if m1 > 32_399 {
|
||||
return None;
|
||||
}
|
||||
let hi = m1 / 180;
|
||||
let lo = m1 % 180;
|
||||
let t = 179u32.checked_sub(hi)?;
|
||||
let loc1 = t / 10; // longitude letter index
|
||||
let loc3 = t % 10; // longitude digit
|
||||
let loc2 = lo / 10; // latitude letter index
|
||||
let loc4 = lo % 10; // latitude digit
|
||||
|
||||
if loc1 > 17 || loc2 > 17 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let grid = format!(
|
||||
"{}{}{}{}",
|
||||
(b'A' + loc1 as u8) as char,
|
||||
(b'A' + loc2 as u8) as char,
|
||||
(b'0' + loc3 as u8) as char,
|
||||
(b'0' + loc4 as u8) as char,
|
||||
);
|
||||
|
||||
Some(format!("{} {} {}", callsign, grid, power_dbm))
|
||||
}
|
||||
|
||||
/// Minimum Fano cumulative path metric to accept a decode.
|
||||
///
|
||||
/// The Fano decoder can sometimes converge on random noise, producing bits
|
||||
/// that happen to unpack into a valid-looking message. The cumulative path
|
||||
/// metric reflects how well the received symbols matched the best trellis
|
||||
/// path. Real WSPR signals at decodable SNR produce metrics well above this
|
||||
/// threshold; noise-induced decodes have metrics near or below zero.
|
||||
const FANO_MIN_METRIC: i64 = 20;
|
||||
|
||||
/// Attempt protocol-level decode from 162 soft-decision symbols.
|
||||
///
|
||||
/// Input: 162 bytes where each value is a soft-decision symbol (0-255):
|
||||
/// 0 = high confidence that data bit is 0
|
||||
/// 128 = no confidence
|
||||
/// 255 = high confidence that data bit is 1
|
||||
pub fn decode_symbols(symbols: &[u8]) -> Option<WsprProtocolMessage> {
|
||||
if symbols.len() < NSYMS {
|
||||
return None;
|
||||
}
|
||||
let coded = deinterleave(symbols);
|
||||
let result = fano_decode(&coded)?;
|
||||
|
||||
// Reject low-confidence decodes that are likely false positives from noise
|
||||
if result.metric < FANO_MIN_METRIC {
|
||||
return None;
|
||||
}
|
||||
|
||||
let message = unpack_message(&result.bits)?;
|
||||
Some(WsprProtocolMessage { message })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::decoder::SYNC_VECTOR;
|
||||
|
||||
/// Encode a WSPR callsign+grid+power into N1/M1/power_code, then round-trip
|
||||
/// through `unpack_message` to verify the pack/unpack formulas are inverse.
|
||||
#[test]
|
||||
fn unpack_known_message() {
|
||||
// Callsign "K1JT", grid "FN20", power 37 dBm — a well-known WSPR beacon.
|
||||
// Encode callsign "K1JT " (padded to 6 chars with trailing spaces).
|
||||
// charset37: ' '=0, '0'=1,..'9'=10, 'A'=11,..'Z'=36
|
||||
// charset27: ' '=0, 'A'=1,..'Z'=26
|
||||
let cs37 = b" 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
let cs27 = b" ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
let idx37 = |c: u8| cs37.iter().position(|&x| x == c).unwrap() as u32;
|
||||
let idx27 = |c: u8| cs27.iter().position(|&x| x == c).unwrap() as u32;
|
||||
|
||||
// " K1JT ": c0=' '=0, c1='K'=21, c2='1', c3='J'=10, c4='T'=20, c5=' '=0
|
||||
let c0 = idx37(b' ');
|
||||
let c1 = idx37(b'K');
|
||||
let c2 = 1u32; // '1'
|
||||
let c3 = idx27(b'J');
|
||||
let c4 = idx27(b'T');
|
||||
let c5 = idx27(b' ');
|
||||
|
||||
let n1 = ((c0 * 36 + c1) * 10 + c2) * 27u32.pow(3) + c3 * 27u32.pow(2) + c4 * 27 + c5;
|
||||
|
||||
// Grid "FN20": loc1='F'=5 (lon), loc2='N'=13 (lat), loc3='2', loc4='0'
|
||||
let loc1 = (b'F' - b'A') as u32; // 5
|
||||
let loc2 = (b'N' - b'A') as u32; // 13
|
||||
let loc3 = 2u32;
|
||||
let loc4 = 0u32;
|
||||
let m1 = (179 - 10 * loc1 - loc3) * 180 + 10 * loc2 + loc4;
|
||||
|
||||
// Power 37 dBm → power_code = 37 (raw dBm value)
|
||||
let power_code = 37u32;
|
||||
|
||||
// Pack into 50-bit array
|
||||
let mut bits = [0u8; NBITS];
|
||||
for i in (0..28).rev() {
|
||||
bits[27 - i] = ((n1 >> i) & 1) as u8;
|
||||
}
|
||||
for i in (0..15).rev() {
|
||||
bits[42 - i] = ((m1 >> i) & 1) as u8;
|
||||
}
|
||||
for i in (0..7).rev() {
|
||||
bits[49 - i] = ((power_code >> i) & 1) as u8;
|
||||
}
|
||||
|
||||
let msg = unpack_message(&bits).expect("unpack_message should succeed");
|
||||
// Message should contain callsign, grid, and power
|
||||
assert!(msg.contains("K1JT"), "callsign not found in '{}'", msg);
|
||||
assert!(msg.contains("FN20"), "grid not found in '{}'", msg);
|
||||
assert!(msg.contains("37"), "power not found in '{}'", msg);
|
||||
}
|
||||
|
||||
/// Convolutionally encode 81 bits → 162 coded bits (for testing).
|
||||
fn convolutional_encode(input: &[u8; NBITS]) -> [u8; NSYMS] {
|
||||
let mut coded = [0u8; NSYMS];
|
||||
let mut encstate: u32 = 0;
|
||||
for k in 0..NBITS {
|
||||
encstate = (encstate << 1) | input[k] as u32;
|
||||
coded[2 * k] = ((encstate & POLY1).count_ones() & 1) as u8;
|
||||
coded[2 * k + 1] = ((encstate & POLY2).count_ones() & 1) as u8;
|
||||
}
|
||||
coded
|
||||
}
|
||||
|
||||
/// Interleave coded bits (inverse of deinterleave).
|
||||
fn interleave(coded: &[u8; NSYMS]) -> [u8; NSYMS] {
|
||||
let mut out = [0u8; NSYMS];
|
||||
let mut p = 0usize;
|
||||
for i in 0u16..=255 {
|
||||
let j = rev8(i as u8) as usize;
|
||||
if j < NSYMS {
|
||||
out[j] = coded[p];
|
||||
p += 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// End-to-end test: encode K1JT FN20 37, produce perfect soft symbols,
|
||||
/// and verify round-trip decode.
|
||||
#[test]
|
||||
fn roundtrip_encode_decode() {
|
||||
let cs37 = b" 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
let cs27 = b" ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
let idx37 = |c: u8| cs37.iter().position(|&x| x == c).unwrap() as u32;
|
||||
let idx27 = |c: u8| cs27.iter().position(|&x| x == c).unwrap() as u32;
|
||||
|
||||
let c0 = idx37(b' ');
|
||||
let c1 = idx37(b'K');
|
||||
let c2 = 1u32;
|
||||
let c3 = idx27(b'J');
|
||||
let c4 = idx27(b'T');
|
||||
let c5 = idx27(b' ');
|
||||
let n1 = ((c0 * 36 + c1) * 10 + c2) * 27u32.pow(3) + c3 * 27u32.pow(2) + c4 * 27 + c5;
|
||||
let m1 = (179 - 10 * 5 - 2) * 180 + 10 * 13 + 0; // FN20
|
||||
let power_code = 37u32;
|
||||
|
||||
let mut input_bits = [0u8; NBITS];
|
||||
for i in (0..28).rev() {
|
||||
input_bits[27 - i] = ((n1 >> i) & 1) as u8;
|
||||
}
|
||||
for i in (0..15).rev() {
|
||||
input_bits[42 - i] = ((m1 >> i) & 1) as u8;
|
||||
}
|
||||
for i in (0..7).rev() {
|
||||
input_bits[49 - i] = ((power_code >> i) & 1) as u8;
|
||||
}
|
||||
// bits 50..80 are tail (zeros), already set
|
||||
|
||||
// Convolutional encode
|
||||
let coded = convolutional_encode(&input_bits);
|
||||
|
||||
// Interleave
|
||||
let interleaved = interleave(&coded);
|
||||
|
||||
// Create channel symbols: symbol[i] = sync[i] + 2*data_bit[i]
|
||||
let channel_syms: Vec<u8> = (0..NSYMS)
|
||||
.map(|i| SYNC_VECTOR[i] + 2 * interleaved[i])
|
||||
.collect();
|
||||
|
||||
// Create perfect soft symbols from channel symbols.
|
||||
// data_bit = channel_sym >> 1. Soft: 0 if data=0, 255 if data=1.
|
||||
let soft: Vec<u8> = channel_syms
|
||||
.iter()
|
||||
.map(|&cs| if cs >> 1 == 1 { 255u8 } else { 0u8 })
|
||||
.collect();
|
||||
|
||||
// Decode
|
||||
let result = decode_symbols(&soft);
|
||||
assert!(result.is_some(), "decode_symbols should succeed");
|
||||
let msg = result.unwrap().message;
|
||||
assert!(msg.contains("K1JT"), "callsign not found in '{msg}'");
|
||||
assert!(msg.contains("FN20"), "grid not found in '{msg}'");
|
||||
assert!(msg.contains("37"), "power not found in '{msg}'");
|
||||
}
|
||||
|
||||
/// Verify deinterleave is the inverse of interleave.
|
||||
#[test]
|
||||
fn interleave_deinterleave_roundtrip() {
|
||||
// Create a sequence of distinguishable values
|
||||
let mut original = [0u8; NSYMS];
|
||||
for i in 0..NSYMS {
|
||||
original[i] = (i % 256) as u8;
|
||||
}
|
||||
|
||||
let interleaved = interleave(&original);
|
||||
let recovered = deinterleave(&interleaved);
|
||||
assert_eq!(original, recovered, "deinterleave should invert interleave");
|
||||
}
|
||||
|
||||
/// Verify that the Fano decoder can decode a convolutionally-encoded message
|
||||
/// with perfect soft symbols (0 and 255).
|
||||
#[test]
|
||||
fn fano_decode_perfect_soft_symbols() {
|
||||
// Create a simple 81-bit message (50 payload + 31 tail zeros)
|
||||
let mut input_bits = [0u8; NBITS];
|
||||
// Set some payload bits to a recognizable pattern
|
||||
input_bits[0] = 1;
|
||||
input_bits[5] = 1;
|
||||
input_bits[10] = 1;
|
||||
input_bits[15] = 1;
|
||||
input_bits[20] = 1;
|
||||
|
||||
// Encode
|
||||
let coded = convolutional_encode(&input_bits);
|
||||
|
||||
// Convert to perfect soft symbols: coded_bit=0 → 0, coded_bit=1 → 255
|
||||
let mut soft = [0u8; NSYMS];
|
||||
for i in 0..NSYMS {
|
||||
soft[i] = if coded[i] == 1 { 255 } else { 0 };
|
||||
}
|
||||
|
||||
// Fano decode (already in coded order, no interleaving needed)
|
||||
let result = fano_decode(&soft);
|
||||
assert!(result.is_some(), "Fano decoder should succeed");
|
||||
let result = result.unwrap();
|
||||
assert_eq!(
|
||||
&result.bits[..NBITS],
|
||||
&input_bits[..NBITS],
|
||||
"Decoded bits should match input"
|
||||
);
|
||||
assert!(
|
||||
result.metric > 0,
|
||||
"Path metric should be positive for perfect symbols"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
[package]
|
||||
name = "trx-wxsat"
|
||||
version.workspace = true
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
trx-core = { path = "../../trx-core" }
|
||||
rustfft = "6"
|
||||
num-complex = "0.4"
|
||||
image = { version = "0.24", default-features = false, features = ["png"] }
|
||||
@@ -0,0 +1,135 @@
|
||||
# trx-wxsat
|
||||
|
||||
Weather satellite image decoders for NOAA APT and Meteor-M LRPT signals.
|
||||
|
||||
## Supported Satellites
|
||||
|
||||
| Satellite | Format | Frequency | Modulation |
|
||||
|----------------|--------|---------------|------------------------|
|
||||
| NOAA-15 | APT | 137.620 MHz | FM/AM subcarrier |
|
||||
| NOAA-18 | APT | 137.9125 MHz | FM/AM subcarrier |
|
||||
| NOAA-19 | APT | 137.100 MHz | FM/AM subcarrier |
|
||||
| Meteor-M N2-3 | LRPT | 137.900 MHz | QPSK, 72 kbps, CCSDS |
|
||||
| Meteor-M N2-4 | LRPT | 137.100 MHz | QPSK, 72 kbps, CCSDS |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
trx-wxsat/src/
|
||||
├── lib.rs # Module declarations, shared helpers
|
||||
├── image_enc.rs # Shared PNG encoding (grayscale + RGB)
|
||||
├── noaa/
|
||||
│ ├── mod.rs # AptDecoder, AptImage (public API)
|
||||
│ ├── apt.rs # AM demodulator (Hilbert/FFT), line sync tracker
|
||||
│ ├── image_enc.rs # APT-specific dual-channel image assembly
|
||||
│ └── telemetry.rs # Wedge-based calibration, satellite ID, histogram EQ
|
||||
└── lrpt/
|
||||
├── mod.rs # LrptDecoder, LrptImage (public API)
|
||||
├── demod.rs # QPSK demodulator (Costas loop + Gardner TED)
|
||||
├── cadu.rs # CCSDS CADU frame synchronisation (ASM search)
|
||||
└── mcu.rs # Per-APID channel assembly, RGB composite
|
||||
```
|
||||
|
||||
## Signal Flow
|
||||
|
||||
### NOAA APT
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A["FM-demodulated audio<br/>(any sample rate)"] -->|"AptDemod: FFT Hilbert transform,<br/>bandpass @ 2400 Hz ±1040 Hz"| B["AM envelope<br/>resampled to 4160 Hz"]
|
||||
B -->|"SyncTracker: 1040 Hz<br/>sync-A marker correlation"| C["Aligned 2080-sample lines<br/>[SyncA 39][SpaceA 47][ImageA 909][TelA 45]<br/>[SyncB 39][SpaceB 47][ImageB 909][TelB 45]"]
|
||||
C -->|"Telemetry extraction,<br/>wedge-based radiometric calibration,<br/>histogram EQ"| D["PNG image<br/>(1818 x N pixels, dual-channel side-by-side)"]
|
||||
```
|
||||
|
||||
**Key DSP details:**
|
||||
|
||||
- AM envelope extraction uses an FFT-based Hilbert transform (rustfft) with
|
||||
bandpass filtering around the 2400 Hz subcarrier
|
||||
- Sync detection uses cosine correlation against a 7-cycle 1040 Hz reference
|
||||
pattern, normalised by RMS; threshold 0.15 for acquisition, 0.075 for tracking
|
||||
- Telemetry frames span 128 lines; wedges 1-8 provide known reference levels
|
||||
for piecewise-linear radiometric calibration; wedge 9 encodes the sensor
|
||||
channel ID
|
||||
- Satellite identification is heuristic, based on the detected channel pairing
|
||||
(e.g. VIS + TIR4 maps to NOAA-18)
|
||||
- Per-line normalisation clips to the 2nd-98th percentile before scaling
|
||||
|
||||
### Meteor-M LRPT
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A["Baseband samples<br/>(any sample rate)"] -->|"QpskDemod: Costas loop carrier recovery<br/>+ Gardner TED symbol timing"| B["Soft symbols (±1.0, I/Q interleaved)<br/>@ 72 ksym/s"]
|
||||
B -->|"CaduFramer: hard-decision,<br/>ASM (0x1ACFFC1D) search, frame lock"| C["1024-byte CADUs<br/>(CCSDS transfer frames)"]
|
||||
C -->|"ChannelAssembler:<br/>VCID → APID routing, MCU extraction"| D["Per-APID pixel rows<br/>(1568 px wide)"]
|
||||
D -->|"RGB composite (APIDs 64/65/66)<br/>or grayscale fallback"| E["PNG image<br/>(1568 x N pixels)"]
|
||||
```
|
||||
|
||||
**LRPT channel mapping:**
|
||||
|
||||
| APID | Channel | Band |
|
||||
|------|---------|-------------------|
|
||||
| 64 | 1 | Visible (0.5-0.7 µm) |
|
||||
| 65 | 2 | Visible/NIR (0.7-1.1 µm) |
|
||||
| 66 | 3 | Near-IR (1.6-1.8 µm) |
|
||||
| 67 | 4 | Mid-IR (3.5-4.1 µm) |
|
||||
| 68 | 5 | Thermal IR (10.5-11.5 µm) |
|
||||
| 69 | 6 | Thermal IR (11.5-12.5 µm) |
|
||||
|
||||
**Key DSP details:**
|
||||
|
||||
- Costas loop parameters: bandwidth ~0.01 of symbol rate, damping factor 0.707
|
||||
- Gardner TED operates on interpolated mid-sample points for timing error
|
||||
estimation
|
||||
- Frame synchronisation searches for the 4-byte Attached Sync Marker
|
||||
(`0x1ACFFC1D`) and maintains lock/unlock state tracking
|
||||
- Spacecraft ID extraction from VCDU header: ID 57 = Meteor-M N2-3,
|
||||
ID 58 = Meteor-M N2-4
|
||||
- RGB compositing uses channels 1/2/3 when available; falls back to the
|
||||
highest-populated single channel as grayscale
|
||||
|
||||
## Public API
|
||||
|
||||
Both decoders share the same streaming interface:
|
||||
|
||||
```rust
|
||||
// NOAA APT
|
||||
let mut apt = AptDecoder::new(sample_rate);
|
||||
apt.process_samples(&audio_batch); // returns new line count
|
||||
apt.line_count(); // total lines so far
|
||||
let image: Option<AptImage> = apt.finalize(); // PNG + telemetry
|
||||
apt.reset(); // prepare for next pass
|
||||
|
||||
// Meteor-M LRPT
|
||||
let mut lrpt = LrptDecoder::new(sample_rate);
|
||||
lrpt.process_samples(&baseband_batch); // returns new MCU row count
|
||||
lrpt.mcu_count(); // total MCU rows so far
|
||||
let image: Option<LrptImage> = lrpt.finalize(); // PNG + metadata
|
||||
lrpt.reset(); // prepare for next pass
|
||||
```
|
||||
|
||||
### Output types
|
||||
|
||||
**`AptImage`**: PNG bytes, line count, first-line timestamp, identified
|
||||
satellite (`NOAA-15`/`18`/`19`), sensor channels A and B
|
||||
(`Visible1`, `NearIr2`, `ThermalIr4`, etc.)
|
||||
|
||||
**`LrptImage`**: PNG bytes, MCU row count, identified satellite
|
||||
(`Meteor-M N2-3`/`N2-4`), comma-separated active APID list
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Crate | Purpose |
|
||||
|---------------|----------------------------------|
|
||||
| `trx-core` | Shared core types |
|
||||
| `rustfft` | FFT for Hilbert AM demodulation |
|
||||
| `num-complex` | Complex arithmetic |
|
||||
| `image` | PNG encoding (png feature only) |
|
||||
|
||||
## Integration
|
||||
|
||||
The crate plugs into `trx-server` as a decoder task. The server feeds PCM
|
||||
audio from the SDR backend into `process_samples()`, auto-finalises on
|
||||
timeout (no new lines/MCUs for a configurable period), and publishes
|
||||
decoded images as `DecodedMessage::WxsatImage` / `DecodedMessage::LrptImage`
|
||||
for client consumption. Images are saved to `~/.cache/trx-rs/wxsat/` and
|
||||
`~/.cache/trx-rs/lrpt/`.
|
||||
@@ -0,0 +1,37 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! Shared PNG image encoding for weather satellite decoders.
|
||||
//!
|
||||
//! The Meteor-M LRPT decoder produces PNG output through this module.
|
||||
|
||||
use std::io::Cursor;
|
||||
|
||||
use image::DynamicImage;
|
||||
|
||||
/// Encode a grayscale pixel buffer as PNG.
|
||||
///
|
||||
/// Returns `None` if the buffer is empty or encoding fails.
|
||||
pub fn encode_grayscale_png(width: u32, height: u32, pixels: Vec<u8>) -> Option<Vec<u8>> {
|
||||
let gray = image::GrayImage::from_raw(width, height, pixels)?;
|
||||
let dynamic = DynamicImage::ImageLuma8(gray);
|
||||
encode_dynamic_png(&dynamic)
|
||||
}
|
||||
|
||||
/// Encode an RGB pixel buffer as PNG.
|
||||
///
|
||||
/// `pixels` must contain `width * height * 3` bytes in R, G, B order.
|
||||
/// Returns `None` if the buffer is empty or encoding fails.
|
||||
pub fn encode_rgb_png(width: u32, height: u32, pixels: Vec<u8>) -> Option<Vec<u8>> {
|
||||
let rgb = image::RgbImage::from_raw(width, height, pixels)?;
|
||||
let dynamic = DynamicImage::ImageRgb8(rgb);
|
||||
encode_dynamic_png(&dynamic)
|
||||
}
|
||||
|
||||
fn encode_dynamic_png(img: &DynamicImage) -> Option<Vec<u8>> {
|
||||
let mut cursor = Cursor::new(Vec::new());
|
||||
img.write_to(&mut cursor, image::ImageOutputFormat::Png)
|
||||
.ok()?;
|
||||
Some(cursor.into_inner())
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! Weather satellite image decoders.
|
||||
//!
|
||||
//! This crate provides a decoder for Meteor-M LRPT (Low Rate Picture
|
||||
//! Transmission) from Meteor-M N2-3/N2-4 using QPSK modulation at 72 kbps
|
||||
//! with CCSDS framing.
|
||||
|
||||
pub mod image_enc;
|
||||
pub mod lrpt;
|
||||
|
||||
/// Current time in milliseconds since UNIX epoch.
|
||||
pub(crate) fn now_ms() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! CCSDS CADU (Channel Access Data Unit) frame synchronisation and extraction.
|
||||
//!
|
||||
//! Meteor-M LRPT uses CCSDS-compatible framing:
|
||||
//! - Attached Sync Marker (ASM): `0x1ACFFC1D` (32 bits)
|
||||
//! - CADU length: 1024 bytes (8192 bits) including ASM
|
||||
//! - Rate 1/2 convolutional coding (Viterbi decoded upstream)
|
||||
//! - Reed-Solomon (255, 223) error correction
|
||||
//!
|
||||
//! The framer correlates against the ASM pattern to find frame boundaries,
|
||||
//! then extracts fixed-length CADUs.
|
||||
|
||||
/// CCSDS Attached Sync Marker for Meteor-M LRPT.
|
||||
const ASM: [u8; 4] = [0x1A, 0xCF, 0xFC, 0x1D];
|
||||
|
||||
/// Total CADU length in bytes (including 4-byte ASM).
|
||||
pub const CADU_LEN: usize = 1024;
|
||||
|
||||
/// CADU payload length (excluding ASM).
|
||||
pub const CADU_PAYLOAD_LEN: usize = CADU_LEN - 4;
|
||||
|
||||
/// Generate the CCSDS pseudo-random derandomization sequence.
|
||||
///
|
||||
/// Polynomial: x^8 + x^7 + x^5 + x^3 + 1, initial state 0xFF.
|
||||
/// The sequence is XOR'd with CADU bytes after the ASM to undo the
|
||||
/// on-board randomization applied before transmission.
|
||||
fn ccsds_derandomize(data: &mut [u8]) {
|
||||
let mut sr: u8 = 0xFF;
|
||||
for byte in data.iter_mut() {
|
||||
*byte ^= sr;
|
||||
for _ in 0..8 {
|
||||
let feedback = ((sr >> 7) ^ (sr >> 5) ^ (sr >> 3) ^ sr) & 1;
|
||||
sr = (sr << 1) | feedback;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A complete CADU frame (1024 bytes including ASM).
|
||||
#[derive(Clone)]
|
||||
pub struct Cadu {
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Cadu {
|
||||
/// VCDU header: spacecraft ID (10 bits starting at byte 4).
|
||||
pub fn spacecraft_id(&self) -> u16 {
|
||||
if self.data.len() < 6 {
|
||||
return 0;
|
||||
}
|
||||
((self.data[4] as u16) << 2) | ((self.data[5] as u16) >> 6)
|
||||
}
|
||||
|
||||
/// VCDU header: virtual channel ID (6 bits).
|
||||
pub fn vcid(&self) -> u8 {
|
||||
if self.data.len() < 6 {
|
||||
return 0;
|
||||
}
|
||||
self.data[5] & 0x3F
|
||||
}
|
||||
|
||||
/// VCDU counter (24 bits, bytes 6-8).
|
||||
pub fn vcdu_counter(&self) -> u32 {
|
||||
if self.data.len() < 9 {
|
||||
return 0;
|
||||
}
|
||||
((self.data[6] as u32) << 16) | ((self.data[7] as u32) << 8) | (self.data[8] as u32)
|
||||
}
|
||||
|
||||
/// MPDU payload region (after VCDU primary header).
|
||||
pub fn mpdu_payload(&self) -> &[u8] {
|
||||
if self.data.len() < 16 {
|
||||
return &[];
|
||||
}
|
||||
// VCDU primary header = 6 bytes, MPDU header pointer = 2 bytes
|
||||
// Payload starts at offset 4 (ASM) + 6 (VCDU hdr) + 2 (MPDU ptr) = 12
|
||||
&self.data[12..]
|
||||
}
|
||||
}
|
||||
|
||||
/// Accumulates soft symbols, performs Viterbi-like hard decisions, and
|
||||
/// searches for ASM to extract complete CADUs.
|
||||
pub struct CaduFramer {
|
||||
/// Bit accumulation buffer.
|
||||
bit_buf: Vec<u8>,
|
||||
/// Byte accumulation buffer for frame extraction.
|
||||
byte_buf: Vec<u8>,
|
||||
/// Whether we are locked to a frame boundary.
|
||||
locked: bool,
|
||||
/// Bytes remaining in the current frame.
|
||||
remaining: usize,
|
||||
}
|
||||
|
||||
impl Default for CaduFramer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl CaduFramer {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
bit_buf: Vec::new(),
|
||||
byte_buf: Vec::new(),
|
||||
locked: false,
|
||||
remaining: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Push soft symbols (interleaved I/Q) and extract any complete CADUs.
|
||||
///
|
||||
/// Soft symbols are hard-decided (threshold at 0.0) and packed into bytes.
|
||||
pub fn push(&mut self, symbols: &[f32]) -> Vec<Cadu> {
|
||||
// Hard-decide symbols to bits
|
||||
for &sym in symbols {
|
||||
self.bit_buf.push(if sym >= 0.0 { 1 } else { 0 });
|
||||
}
|
||||
|
||||
// Pack bits into bytes
|
||||
while self.bit_buf.len() >= 8 {
|
||||
let byte = (self.bit_buf[0] << 7)
|
||||
| (self.bit_buf[1] << 6)
|
||||
| (self.bit_buf[2] << 5)
|
||||
| (self.bit_buf[3] << 4)
|
||||
| (self.bit_buf[4] << 3)
|
||||
| (self.bit_buf[5] << 2)
|
||||
| (self.bit_buf[6] << 1)
|
||||
| self.bit_buf[7];
|
||||
self.byte_buf.push(byte);
|
||||
self.bit_buf.drain(..8);
|
||||
}
|
||||
|
||||
let mut cadus = Vec::new();
|
||||
self.extract_frames(&mut cadus);
|
||||
cadus
|
||||
}
|
||||
|
||||
fn extract_frames(&mut self, cadus: &mut Vec<Cadu>) {
|
||||
loop {
|
||||
if self.locked {
|
||||
if self.byte_buf.len() >= self.remaining {
|
||||
// Collect the rest of the frame
|
||||
let frame_bytes: Vec<u8> = self.byte_buf.drain(..self.remaining).collect();
|
||||
// Prepend ASM to make a complete CADU
|
||||
let mut data = ASM.to_vec();
|
||||
data.extend_from_slice(&frame_bytes);
|
||||
if data.len() == CADU_LEN {
|
||||
// Derandomize payload (everything after 4-byte ASM)
|
||||
ccsds_derandomize(&mut data[4..]);
|
||||
cadus.push(Cadu { data });
|
||||
}
|
||||
self.locked = false;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Search for ASM in the byte buffer
|
||||
if let Some(pos) = find_asm(&self.byte_buf) {
|
||||
// Discard bytes before ASM
|
||||
self.byte_buf.drain(..pos);
|
||||
// Skip the 4 ASM bytes
|
||||
if self.byte_buf.len() >= 4 {
|
||||
self.byte_buf.drain(..4);
|
||||
self.locked = true;
|
||||
self.remaining = CADU_LEN - 4; // payload bytes needed
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// No ASM found; keep last 3 bytes (partial ASM might straddle boundary)
|
||||
if self.byte_buf.len() > 3 {
|
||||
let keep = self.byte_buf.len().saturating_sub(3);
|
||||
self.byte_buf.drain(..keep);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
self.bit_buf.clear();
|
||||
self.byte_buf.clear();
|
||||
self.locked = false;
|
||||
self.remaining = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the ASM pattern in a byte buffer; returns the offset if found.
|
||||
fn find_asm(buf: &[u8]) -> Option<usize> {
|
||||
if buf.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
(0..=(buf.len() - 4)).find(|&i| {
|
||||
buf[i] == ASM[0] && buf[i + 1] == ASM[1] && buf[i + 2] == ASM[2] && buf[i + 3] == ASM[3]
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_find_asm() {
|
||||
let buf = [0x00, 0x1A, 0xCF, 0xFC, 0x1D, 0x00];
|
||||
assert_eq!(find_asm(&buf), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_asm_at_start() {
|
||||
let buf = [0x1A, 0xCF, 0xFC, 0x1D, 0x00];
|
||||
assert_eq!(find_asm(&buf), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_asm_not_found() {
|
||||
let buf = [0x00, 0x01, 0x02, 0x03, 0x04];
|
||||
assert_eq!(find_asm(&buf), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_derandomize_roundtrip() {
|
||||
let original = vec![0xAB; CADU_PAYLOAD_LEN];
|
||||
let mut data = original.clone();
|
||||
// Randomize
|
||||
ccsds_derandomize(&mut data);
|
||||
// Should differ from original
|
||||
assert_ne!(data, original);
|
||||
// Derandomize again (same sequence) should restore
|
||||
ccsds_derandomize(&mut data);
|
||||
assert_eq!(data, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cadu_spacecraft_id() {
|
||||
let mut data = vec![0u8; CADU_LEN];
|
||||
// ASM
|
||||
data[0..4].copy_from_slice(&ASM);
|
||||
// Spacecraft ID = 0x0C3 (195) in bits [4*8..4*8+10]
|
||||
// byte 4 = 0x30 (top 8 bits: 00110000), byte 5 bits 7-6 = 11
|
||||
data[4] = 0x30;
|
||||
data[5] = 0xC0;
|
||||
let cadu = Cadu { data };
|
||||
assert_eq!(cadu.spacecraft_id(), 0xC3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! QPSK demodulator for Meteor-M LRPT.
|
||||
//!
|
||||
//! Meteor-M transmits LRPT at 72 kbps using offset-QPSK modulation on a
|
||||
//! ~137 MHz carrier. The symbol rate is 72000 symbols/sec.
|
||||
//!
|
||||
//! This module implements:
|
||||
//! - Costas loop for carrier and phase recovery
|
||||
//! - Gardner timing error detector for symbol synchronisation
|
||||
//! - Soft-decision symbol output (±1.0 for I and Q)
|
||||
|
||||
use num_complex::Complex;
|
||||
|
||||
const SYMBOL_RATE: f64 = 72_000.0;
|
||||
|
||||
/// QPSK demodulator with carrier and timing recovery.
|
||||
pub struct QpskDemod {
|
||||
/// Samples per symbol.
|
||||
sps: f64,
|
||||
/// NCO phase (radians).
|
||||
nco_phase: f64,
|
||||
/// NCO frequency offset estimate (radians/sample).
|
||||
nco_freq: f64,
|
||||
/// Costas loop bandwidth parameter.
|
||||
costas_alpha: f64,
|
||||
costas_beta: f64,
|
||||
/// Symbol timing accumulator (fractional sample position).
|
||||
timing_accum: f64,
|
||||
/// Gardner TED state.
|
||||
prev_sample: Complex<f32>,
|
||||
mid_sample: Complex<f32>,
|
||||
/// Soft symbol output buffer.
|
||||
out: Vec<f32>,
|
||||
}
|
||||
|
||||
impl QpskDemod {
|
||||
pub fn new(sample_rate: u32) -> Self {
|
||||
let sps = sample_rate as f64 / SYMBOL_RATE;
|
||||
// Costas loop BW ~ 0.01 of symbol rate
|
||||
let bw = 0.01;
|
||||
let damp = 0.707;
|
||||
let alpha = 4.0 * damp * bw / (1.0 + 2.0 * damp * bw + bw * bw);
|
||||
let beta = 4.0 * bw * bw / (1.0 + 2.0 * damp * bw + bw * bw);
|
||||
|
||||
Self {
|
||||
sps,
|
||||
nco_phase: 0.0,
|
||||
nco_freq: 0.0,
|
||||
costas_alpha: alpha,
|
||||
costas_beta: beta,
|
||||
timing_accum: 0.0,
|
||||
prev_sample: Complex::new(0.0, 0.0),
|
||||
mid_sample: Complex::new(0.0, 0.0),
|
||||
out: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Push raw baseband samples; returns soft symbol pairs (I, Q interleaved).
|
||||
pub fn push(&mut self, samples: &[f32]) -> Vec<f32> {
|
||||
self.out.clear();
|
||||
|
||||
for &s in samples {
|
||||
// Mix with NCO to remove carrier offset
|
||||
let lo = Complex::new(self.nco_phase.cos() as f32, (-self.nco_phase.sin()) as f32);
|
||||
let mixed = Complex::new(s, 0.0) * lo;
|
||||
|
||||
// Symbol timing via Gardner TED
|
||||
self.timing_accum += 1.0;
|
||||
|
||||
if self.timing_accum >= self.sps {
|
||||
self.timing_accum -= self.sps;
|
||||
|
||||
// Costas loop phase error (QPSK: sgn(I)*Q - sgn(Q)*I)
|
||||
let phase_err = mixed.re.signum() * mixed.im - mixed.im.signum() * mixed.re;
|
||||
|
||||
// Update NCO
|
||||
self.nco_freq += self.costas_beta * phase_err as f64;
|
||||
self.nco_phase += self.costas_alpha * phase_err as f64;
|
||||
|
||||
// Gardner TED for timing
|
||||
let ted_err = self.mid_sample.re * (self.prev_sample.re - mixed.re)
|
||||
+ self.mid_sample.im * (self.prev_sample.im - mixed.im);
|
||||
self.timing_accum += 0.5 * ted_err as f64;
|
||||
|
||||
// Output soft symbols
|
||||
self.out.push(mixed.re);
|
||||
self.out.push(mixed.im);
|
||||
|
||||
self.prev_sample = mixed;
|
||||
} else if (self.timing_accum - self.sps / 2.0).abs() < 0.5 {
|
||||
self.mid_sample = mixed;
|
||||
}
|
||||
|
||||
// Advance NCO
|
||||
self.nco_phase += self.nco_freq;
|
||||
if self.nco_phase > std::f64::consts::TAU {
|
||||
self.nco_phase -= std::f64::consts::TAU;
|
||||
} else if self.nco_phase < 0.0 {
|
||||
self.nco_phase += std::f64::consts::TAU;
|
||||
}
|
||||
}
|
||||
|
||||
std::mem::take(&mut self.out)
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
self.nco_phase = 0.0;
|
||||
self.nco_freq = 0.0;
|
||||
self.timing_accum = 0.0;
|
||||
self.prev_sample = Complex::new(0.0, 0.0);
|
||||
self.mid_sample = Complex::new(0.0, 0.0);
|
||||
self.out.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,758 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! MCU (Minimum Coded Unit) assembly and multi-channel image composition.
|
||||
//!
|
||||
//! Meteor-M LRPT imagery is transmitted as MCU blocks (8x8 pixel) across
|
||||
//! multiple APIDs (Application Process Identifiers). Each APID corresponds
|
||||
//! to a different sensor channel:
|
||||
//!
|
||||
//! - APID 64: channel 1 (visible, 0.5-0.7 um)
|
||||
//! - APID 65: channel 2 (visible/NIR, 0.7-1.1 um)
|
||||
//! - APID 66: channel 3 (near-IR, 1.6-1.8 um)
|
||||
//! - APID 67: channel 4 (mid-IR, 3.5-4.1 um)
|
||||
//! - APID 68: channel 5 (thermal IR, 10.5-11.5 um)
|
||||
//! - APID 69: channel 6 (thermal IR, 11.5-12.5 um)
|
||||
//!
|
||||
//! The standard colour composite uses APIDs 64 (R), 65 (G), 66 (B) or
|
||||
//! APIDs 65 (R), 65 (G), 68 (B) depending on illumination.
|
||||
//!
|
||||
//! Each CCSDS packet carries compressed MCU data using a JPEG-like scheme:
|
||||
//! Huffman-coded DCT coefficients with fixed quantization and Huffman tables.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::cadu::Cadu;
|
||||
use super::MeteorSatellite;
|
||||
|
||||
/// Image width in pixels (Meteor-M MSU-MR swath: 196 MCU blocks * 8 px).
|
||||
const LINE_WIDTH: u32 = 1568;
|
||||
|
||||
/// Number of 8x8 MCU blocks per image line.
|
||||
const MCUS_PER_LINE: usize = (LINE_WIDTH / 8) as usize;
|
||||
|
||||
/// Known Meteor-M spacecraft IDs.
|
||||
const SPACECRAFT_M2_3: u16 = 57; // Meteor-M N2-3
|
||||
const SPACECRAFT_M2_4: u16 = 58; // Meteor-M N2-4
|
||||
|
||||
// ============================================================================
|
||||
// Meteor-M LRPT JPEG quantization table
|
||||
// ============================================================================
|
||||
|
||||
/// Standard quantization table for Meteor-M LRPT imagery.
|
||||
/// Applied in zigzag order to dequantize DCT coefficients.
|
||||
#[rustfmt::skip]
|
||||
const QUANT_TABLE: [i32; 64] = [
|
||||
16, 11, 10, 16, 24, 40, 51, 61,
|
||||
12, 12, 14, 19, 26, 58, 60, 55,
|
||||
14, 13, 16, 24, 40, 57, 69, 56,
|
||||
14, 17, 22, 29, 51, 87, 80, 62,
|
||||
18, 22, 37, 56, 68, 109, 103, 77,
|
||||
24, 35, 55, 64, 81, 104, 113, 92,
|
||||
49, 64, 78, 87, 103, 121, 120, 101,
|
||||
72, 92, 95, 98, 112, 100, 103, 99,
|
||||
];
|
||||
|
||||
/// JPEG zigzag scan order (maps zigzag index → row-major 8x8 index).
|
||||
#[rustfmt::skip]
|
||||
const ZIGZAG: [usize; 64] = [
|
||||
0, 1, 8, 16, 9, 2, 3, 10,
|
||||
17, 24, 32, 25, 18, 11, 4, 5,
|
||||
12, 19, 26, 33, 40, 48, 41, 34,
|
||||
27, 20, 13, 6, 7, 14, 21, 28,
|
||||
35, 42, 49, 56, 57, 50, 43, 36,
|
||||
29, 22, 15, 23, 30, 37, 44, 51,
|
||||
58, 59, 52, 45, 38, 31, 39, 46,
|
||||
53, 60, 61, 54, 47, 55, 62, 63,
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// Huffman tables for Meteor-M LRPT (standard JPEG baseline tables)
|
||||
// ============================================================================
|
||||
|
||||
/// DC Huffman table: (code_length, code_value) → category.
|
||||
/// Standard JPEG luminance DC table.
|
||||
struct HuffTable {
|
||||
/// For each bit length (1..=16), the codes and their symbol values.
|
||||
entries: Vec<(u8, u16, u8)>, // (bits, code, symbol)
|
||||
}
|
||||
|
||||
impl HuffTable {
|
||||
fn dc_table() -> Self {
|
||||
// Standard JPEG luminance DC Huffman table
|
||||
// Category 0-11, code lengths from JPEG spec
|
||||
#[rustfmt::skip]
|
||||
let symbols_by_length: &[(u8, &[u8])] = &[
|
||||
(2, &[0, 1, 2, 3, 4, 5]),
|
||||
(3, &[6]),
|
||||
(4, &[7]),
|
||||
(5, &[8]),
|
||||
(6, &[9]),
|
||||
(7, &[10]),
|
||||
(8, &[11]),
|
||||
];
|
||||
|
||||
Self::build(symbols_by_length)
|
||||
}
|
||||
|
||||
fn ac_table() -> Self {
|
||||
// Standard JPEG luminance AC Huffman table
|
||||
// Each symbol is (run_length << 4 | category)
|
||||
#[rustfmt::skip]
|
||||
let symbols_by_length: &[(u8, &[u8])] = &[
|
||||
(2, &[0x01, 0x02]),
|
||||
(3, &[0x03]),
|
||||
(4, &[0x00, 0x04, 0x11]),
|
||||
(5, &[0x05, 0x12, 0x21]),
|
||||
(6, &[0x31, 0x41]),
|
||||
(7, &[0x06, 0x13, 0x51, 0x61]),
|
||||
(8, &[0x07, 0x22, 0x71]),
|
||||
(9, &[0x14, 0x32, 0x81, 0x91, 0xA1]),
|
||||
(10, &[0x08, 0x23, 0x42, 0xB1, 0xC1]),
|
||||
(11, &[0x15, 0x52, 0xD1, 0xF0]),
|
||||
(12, &[0x24, 0x33, 0x62, 0x72]),
|
||||
(15, &[0x82]),
|
||||
(16, &[0x09, 0x0A, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x25,
|
||||
0x26, 0x27, 0x28, 0x29, 0x2A, 0x34, 0x35, 0x36,
|
||||
0x37, 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46,
|
||||
0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56,
|
||||
0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66,
|
||||
0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75, 0x76,
|
||||
0x77, 0x78, 0x79, 0x7A, 0x83, 0x84, 0x85, 0x86,
|
||||
0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95,
|
||||
0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4,
|
||||
0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3,
|
||||
0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2,
|
||||
0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCA,
|
||||
0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9,
|
||||
0xDA, 0xE1, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7,
|
||||
0xE8, 0xE9, 0xEA, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5,
|
||||
0xF6, 0xF7, 0xF8, 0xF9, 0xFA]),
|
||||
];
|
||||
|
||||
Self::build(symbols_by_length)
|
||||
}
|
||||
|
||||
fn build(symbols_by_length: &[(u8, &[u8])]) -> Self {
|
||||
let mut entries = Vec::new();
|
||||
let mut code: u16 = 0;
|
||||
|
||||
// Sort by bit length to generate canonical Huffman codes
|
||||
let mut all: Vec<(u8, u8)> = Vec::new();
|
||||
for &(bits, syms) in symbols_by_length {
|
||||
for &sym in syms {
|
||||
all.push((bits, sym));
|
||||
}
|
||||
}
|
||||
all.sort_by_key(|&(bits, _)| bits);
|
||||
|
||||
let mut prev_bits = 0u8;
|
||||
for &(bits, sym) in &all {
|
||||
if prev_bits > 0 {
|
||||
code = (code + 1) << (bits - prev_bits);
|
||||
}
|
||||
entries.push((bits, code, sym));
|
||||
prev_bits = bits;
|
||||
}
|
||||
|
||||
Self { entries }
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Bitstream reader
|
||||
// ============================================================================
|
||||
|
||||
struct BitReader<'a> {
|
||||
data: &'a [u8],
|
||||
byte_pos: usize,
|
||||
bit_pos: u8, // 0-7, MSB first
|
||||
}
|
||||
|
||||
impl<'a> BitReader<'a> {
|
||||
fn new(data: &'a [u8]) -> Self {
|
||||
Self {
|
||||
data,
|
||||
byte_pos: 0,
|
||||
bit_pos: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn read_bit(&mut self) -> Option<u8> {
|
||||
if self.byte_pos >= self.data.len() {
|
||||
return None;
|
||||
}
|
||||
let bit = (self.data[self.byte_pos] >> (7 - self.bit_pos)) & 1;
|
||||
self.bit_pos += 1;
|
||||
if self.bit_pos >= 8 {
|
||||
self.bit_pos = 0;
|
||||
self.byte_pos += 1;
|
||||
}
|
||||
Some(bit)
|
||||
}
|
||||
|
||||
fn read_bits(&mut self, count: u8) -> Option<i32> {
|
||||
let mut val: i32 = 0;
|
||||
for _ in 0..count {
|
||||
val = (val << 1) | self.read_bit()? as i32;
|
||||
}
|
||||
Some(val)
|
||||
}
|
||||
|
||||
fn decode_huffman(&mut self, table: &HuffTable) -> Option<u8> {
|
||||
let mut code: u16 = 0;
|
||||
let mut bits_read: u8 = 0;
|
||||
|
||||
loop {
|
||||
let bit = self.read_bit()?;
|
||||
code = (code << 1) | bit as u16;
|
||||
bits_read += 1;
|
||||
|
||||
for &(entry_bits, entry_code, symbol) in &table.entries {
|
||||
if entry_bits == bits_read && entry_code == code {
|
||||
return Some(symbol);
|
||||
}
|
||||
}
|
||||
|
||||
if bits_read >= 16 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn has_remaining(&self) -> bool {
|
||||
self.byte_pos < self.data.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode a signed value from category bits (JPEG magnitude encoding).
|
||||
fn decode_magnitude(category: u8, bits: i32) -> i32 {
|
||||
if category == 0 {
|
||||
return 0;
|
||||
}
|
||||
// If MSB is 0, value is negative
|
||||
if bits < (1 << (category - 1)) {
|
||||
bits - (1 << category) + 1
|
||||
} else {
|
||||
bits
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Inverse DCT (8x8)
|
||||
// ============================================================================
|
||||
|
||||
/// Perform 8x8 inverse discrete cosine transform on dequantized coefficients.
|
||||
fn idct_8x8(coeffs: &[i32; 64], output: &mut [u8; 64]) {
|
||||
// Use the standard IDCT formula with precomputed cosine values.
|
||||
// cos(pi * (2*x + 1) * u / 16) for x,u in 0..8
|
||||
let mut workspace = [0.0f64; 64];
|
||||
|
||||
for y in 0..8 {
|
||||
for x in 0..8 {
|
||||
let mut sum = 0.0f64;
|
||||
for v in 0..8 {
|
||||
for u in 0..8 {
|
||||
let cu = if u == 0 {
|
||||
std::f64::consts::FRAC_1_SQRT_2
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let cv = if v == 0 {
|
||||
std::f64::consts::FRAC_1_SQRT_2
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let coeff = coeffs[v * 8 + u] as f64;
|
||||
let cos_x = (std::f64::consts::PI * (2 * x + 1) as f64 * u as f64 / 16.0).cos();
|
||||
let cos_y = (std::f64::consts::PI * (2 * y + 1) as f64 * v as f64 / 16.0).cos();
|
||||
sum += cu * cv * coeff * cos_x * cos_y;
|
||||
}
|
||||
}
|
||||
workspace[y * 8 + x] = sum / 4.0;
|
||||
}
|
||||
}
|
||||
|
||||
// Level shift (+128) and clamp to [0, 255]
|
||||
for i in 0..64 {
|
||||
let val = (workspace[i] + 128.0).round();
|
||||
output[i] = val.clamp(0.0, 255.0) as u8;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MCU block decoder
|
||||
// ============================================================================
|
||||
|
||||
/// Decode a single 8x8 MCU block from a bitstream.
|
||||
///
|
||||
/// Returns the decoded 64-pixel block and the updated DC prediction value.
|
||||
fn decode_mcu_block(
|
||||
reader: &mut BitReader,
|
||||
dc_table: &HuffTable,
|
||||
ac_table: &HuffTable,
|
||||
prev_dc: i32,
|
||||
) -> Option<([u8; 64], i32)> {
|
||||
let mut coeffs = [0i32; 64];
|
||||
|
||||
// DC coefficient
|
||||
let dc_category = reader.decode_huffman(dc_table)?;
|
||||
let dc_bits = if dc_category > 0 {
|
||||
reader.read_bits(dc_category)?
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let dc_diff = decode_magnitude(dc_category, dc_bits);
|
||||
let dc_val = prev_dc + dc_diff;
|
||||
coeffs[0] = dc_val;
|
||||
|
||||
// AC coefficients (zigzag positions 1-63)
|
||||
let mut idx = 1;
|
||||
while idx < 64 {
|
||||
let symbol = reader.decode_huffman(ac_table)?;
|
||||
if symbol == 0x00 {
|
||||
// EOB — remaining coefficients are zero
|
||||
break;
|
||||
}
|
||||
let run = (symbol >> 4) as usize;
|
||||
let category = symbol & 0x0F;
|
||||
|
||||
if symbol == 0xF0 {
|
||||
// ZRL — skip 16 zeros
|
||||
idx += 16;
|
||||
continue;
|
||||
}
|
||||
|
||||
idx += run;
|
||||
if idx >= 64 {
|
||||
break;
|
||||
}
|
||||
|
||||
let ac_bits = if category > 0 {
|
||||
reader.read_bits(category)?
|
||||
} else {
|
||||
0
|
||||
};
|
||||
coeffs[idx] = decode_magnitude(category, ac_bits);
|
||||
idx += 1;
|
||||
}
|
||||
|
||||
// De-zigzag and dequantize
|
||||
let mut dequant = [0i32; 64];
|
||||
for i in 0..64 {
|
||||
dequant[ZIGZAG[i]] = coeffs[i] * QUANT_TABLE[i];
|
||||
}
|
||||
|
||||
// Inverse DCT
|
||||
let mut pixels = [0u8; 64];
|
||||
idct_8x8(&dequant, &mut pixels);
|
||||
|
||||
Some((pixels, dc_val))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Channel buffer and assembler
|
||||
// ============================================================================
|
||||
|
||||
/// Per-APID channel accumulator.
|
||||
struct ChannelBuffer {
|
||||
/// Row-major pixel data (grayscale, 0-255).
|
||||
pixels: Vec<u8>,
|
||||
/// Number of complete image lines accumulated.
|
||||
lines: u32,
|
||||
/// Current MCU column position within the current MCU row.
|
||||
mcu_col: usize,
|
||||
/// Row buffer for the current MCU row (8 lines * LINE_WIDTH pixels).
|
||||
row_buf: Vec<u8>,
|
||||
/// DC prediction value for differential coding.
|
||||
prev_dc: i32,
|
||||
}
|
||||
|
||||
impl ChannelBuffer {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
pixels: Vec::new(),
|
||||
lines: 0,
|
||||
mcu_col: 0,
|
||||
row_buf: vec![0u8; 8 * LINE_WIDTH as usize],
|
||||
prev_dc: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Write an 8x8 MCU block at the current column position.
|
||||
fn push_mcu_block(&mut self, block: &[u8; 64]) {
|
||||
let col = self.mcu_col;
|
||||
if col >= MCUS_PER_LINE {
|
||||
// Flush the current MCU row to pixels, start a new one
|
||||
self.flush_mcu_row();
|
||||
}
|
||||
|
||||
let x_off = self.mcu_col * 8;
|
||||
for row in 0..8 {
|
||||
let dst_start = row * LINE_WIDTH as usize + x_off;
|
||||
let src_start = row * 8;
|
||||
if dst_start + 8 <= self.row_buf.len() {
|
||||
self.row_buf[dst_start..dst_start + 8]
|
||||
.copy_from_slice(&block[src_start..src_start + 8]);
|
||||
}
|
||||
}
|
||||
self.mcu_col += 1;
|
||||
|
||||
// If we've filled a complete MCU row, flush it
|
||||
if self.mcu_col >= MCUS_PER_LINE {
|
||||
self.flush_mcu_row();
|
||||
}
|
||||
}
|
||||
|
||||
fn flush_mcu_row(&mut self) {
|
||||
if self.mcu_col == 0 {
|
||||
return;
|
||||
}
|
||||
self.pixels.extend_from_slice(&self.row_buf);
|
||||
self.lines += 8;
|
||||
self.row_buf.fill(0);
|
||||
self.mcu_col = 0;
|
||||
}
|
||||
|
||||
/// Push raw pixel data as a fallback (one LINE_WIDTH row at a time).
|
||||
fn push_raw_row(&mut self, data: &[u8]) {
|
||||
self.pixels.extend_from_slice(data);
|
||||
self.lines = (self.pixels.len() / LINE_WIDTH as usize) as u32;
|
||||
}
|
||||
}
|
||||
|
||||
/// Assembles decoded MCU blocks from multiple APIDs into a composite image.
|
||||
pub struct ChannelAssembler {
|
||||
/// Per-APID buffers.
|
||||
channels: BTreeMap<u16, ChannelBuffer>,
|
||||
/// Total MCU rows across all channels.
|
||||
total_mcu_count: u32,
|
||||
/// Spacecraft ID seen in CADUs (for satellite identification).
|
||||
spacecraft_id: Option<u16>,
|
||||
/// Huffman tables (built once).
|
||||
dc_table: HuffTable,
|
||||
ac_table: HuffTable,
|
||||
/// Partial CCSDS packet reassembly buffer, keyed by APID.
|
||||
packet_buf: BTreeMap<u16, Vec<u8>>,
|
||||
}
|
||||
|
||||
impl Default for ChannelAssembler {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ChannelAssembler {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
channels: BTreeMap::new(),
|
||||
total_mcu_count: 0,
|
||||
spacecraft_id: None,
|
||||
dc_table: HuffTable::dc_table(),
|
||||
ac_table: HuffTable::ac_table(),
|
||||
packet_buf: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Process a single CADU, extracting MCU data for each APID found.
|
||||
pub fn process_cadu(&mut self, cadu: &Cadu) {
|
||||
// Record spacecraft ID
|
||||
let scid = cadu.spacecraft_id();
|
||||
if scid > 0 {
|
||||
self.spacecraft_id = Some(scid);
|
||||
}
|
||||
|
||||
let vcid = cadu.vcid();
|
||||
let payload = cadu.mpdu_payload();
|
||||
|
||||
// Virtual channels 0-5 carry APID 64-69 imagery
|
||||
if vcid > 5 || payload.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let apid = 64 + vcid as u16;
|
||||
|
||||
// Parse first header pointer from MPDU header.
|
||||
// The 2 bytes before the payload in the CADU (at offset 10-11 after ASM)
|
||||
// contain the first header pointer. If 0x07FF, no packet starts here.
|
||||
let fhp = if cadu.data.len() >= 12 {
|
||||
((cadu.data[10] as u16 & 0x07) << 8) | cadu.data[11] as u16
|
||||
} else {
|
||||
0x07FF
|
||||
};
|
||||
|
||||
if fhp == 0x07FF {
|
||||
// No new packet starts in this MPDU — append to ongoing packet
|
||||
self.packet_buf
|
||||
.entry(apid)
|
||||
.or_default()
|
||||
.extend_from_slice(payload);
|
||||
} else {
|
||||
let fhp = fhp as usize;
|
||||
|
||||
// Complete the previous packet with data before the pointer
|
||||
if fhp > 0 && fhp <= payload.len() {
|
||||
if let Some(buf) = self.packet_buf.get_mut(&apid) {
|
||||
buf.extend_from_slice(&payload[..fhp]);
|
||||
let packet_data = std::mem::take(buf);
|
||||
self.decode_packet(apid, &packet_data);
|
||||
}
|
||||
}
|
||||
|
||||
// Start new packet from the first header pointer
|
||||
if fhp < payload.len() {
|
||||
let buf = self.packet_buf.entry(apid).or_default();
|
||||
buf.clear();
|
||||
buf.extend_from_slice(&payload[fhp..]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempt to decode MCU blocks from a reassembled CCSDS packet.
|
||||
fn decode_packet(&mut self, apid: u16, data: &[u8]) {
|
||||
// CCSDS source packet: 6-byte primary header + data zone
|
||||
if data.len() < 10 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip 6-byte CCSDS primary header + 4 bytes of secondary header
|
||||
// to reach the compressed MCU data
|
||||
let mcu_data = &data[10..];
|
||||
if mcu_data.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let buf = self.channels.entry(apid).or_insert_with(ChannelBuffer::new);
|
||||
|
||||
// Try JPEG MCU decompression
|
||||
let mut reader = BitReader::new(mcu_data);
|
||||
let mut blocks_decoded = 0u32;
|
||||
|
||||
while reader.has_remaining() {
|
||||
match decode_mcu_block(&mut reader, &self.dc_table, &self.ac_table, buf.prev_dc) {
|
||||
Some((block, new_dc)) => {
|
||||
buf.prev_dc = new_dc;
|
||||
buf.push_mcu_block(&block);
|
||||
blocks_decoded += 1;
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
|
||||
if blocks_decoded > 0 {
|
||||
self.total_mcu_count += blocks_decoded;
|
||||
} else if mcu_data.len() >= LINE_WIDTH as usize {
|
||||
// Fallback: if JPEG decode fails entirely, try as raw data
|
||||
let usable = mcu_data.len().min(LINE_WIDTH as usize);
|
||||
let mut row = vec![0u8; LINE_WIDTH as usize];
|
||||
row[..usable].copy_from_slice(&mcu_data[..usable]);
|
||||
buf.push_raw_row(&row);
|
||||
self.total_mcu_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Total MCU rows decoded across all channels.
|
||||
pub fn mcu_count(&self) -> u32 {
|
||||
self.total_mcu_count
|
||||
}
|
||||
|
||||
/// Active APID channels.
|
||||
pub fn active_apids(&self) -> Vec<u16> {
|
||||
self.channels.keys().copied().collect()
|
||||
}
|
||||
|
||||
/// Identify the satellite from the CCSDS spacecraft ID.
|
||||
pub fn identify_satellite(&self) -> Option<MeteorSatellite> {
|
||||
self.spacecraft_id.map(|id| match id {
|
||||
SPACECRAFT_M2_3 => MeteorSatellite::MeteorM2_3,
|
||||
SPACECRAFT_M2_4 => MeteorSatellite::MeteorM2_4,
|
||||
_ => MeteorSatellite::Unknown,
|
||||
})
|
||||
}
|
||||
|
||||
/// Encode accumulated channel data as a PNG image.
|
||||
///
|
||||
/// Produces an RGB composite if channels 64, 65, 66 are available,
|
||||
/// otherwise produces a grayscale image of the most populated channel.
|
||||
pub fn encode_png(&self) -> Option<Vec<u8>> {
|
||||
if self.channels.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Flush any partial MCU rows by computing effective heights
|
||||
let max_lines = self
|
||||
.channels
|
||||
.values()
|
||||
.map(|ch| {
|
||||
let extra = if ch.mcu_col > 0 { 8 } else { 0 };
|
||||
ch.lines + extra
|
||||
})
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
|
||||
if max_lines == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let width = LINE_WIDTH;
|
||||
let height = max_lines;
|
||||
let npix = (width * height) as usize;
|
||||
|
||||
// Helper to get pixel data including unflushed MCU row
|
||||
let get_pixels = |ch: &ChannelBuffer| -> Vec<u8> {
|
||||
let mut px = ch.pixels.clone();
|
||||
if ch.mcu_col > 0 {
|
||||
px.extend_from_slice(&ch.row_buf);
|
||||
}
|
||||
px
|
||||
};
|
||||
|
||||
// Try RGB composite (APIDs 64=R, 65=G, 66=B)
|
||||
let ch_r = self.channels.get(&64);
|
||||
let ch_g = self.channels.get(&65);
|
||||
let ch_b = self.channels.get(&66);
|
||||
|
||||
if ch_r.is_some() || ch_g.is_some() || ch_b.is_some() {
|
||||
let px_r = ch_r.map(get_pixels);
|
||||
let px_g = ch_g.map(get_pixels);
|
||||
let px_b = ch_b.map(get_pixels);
|
||||
|
||||
let mut rgb_pixels: Vec<u8> = Vec::with_capacity(npix * 3);
|
||||
for i in 0..npix {
|
||||
let r = px_r.as_ref().and_then(|p| p.get(i).copied()).unwrap_or(0);
|
||||
let g = px_g.as_ref().and_then(|p| p.get(i).copied()).unwrap_or(0);
|
||||
let b = px_b.as_ref().and_then(|p| p.get(i).copied()).unwrap_or(0);
|
||||
rgb_pixels.push(r);
|
||||
rgb_pixels.push(g);
|
||||
rgb_pixels.push(b);
|
||||
}
|
||||
crate::image_enc::encode_rgb_png(width, height, rgb_pixels)
|
||||
} else {
|
||||
// Fallback: grayscale from the first available channel
|
||||
let first_ch = self.channels.values().next()?;
|
||||
let px = get_pixels(first_ch);
|
||||
let mut gray_pixels: Vec<u8> = Vec::with_capacity(npix);
|
||||
for i in 0..npix {
|
||||
gray_pixels.push(px.get(i).copied().unwrap_or(0));
|
||||
}
|
||||
crate::image_enc::encode_grayscale_png(width, height, gray_pixels)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
self.channels.clear();
|
||||
self.total_mcu_count = 0;
|
||||
self.spacecraft_id = None;
|
||||
self.packet_buf.clear();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_channel_buffer_line_counting() {
|
||||
let mut buf = ChannelBuffer::new();
|
||||
let row = vec![128u8; LINE_WIDTH as usize];
|
||||
buf.push_raw_row(&row);
|
||||
assert_eq!(buf.lines, 1);
|
||||
buf.push_raw_row(&row);
|
||||
assert_eq!(buf.lines, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mcu_block_placement() {
|
||||
let mut buf = ChannelBuffer::new();
|
||||
let block = [200u8; 64];
|
||||
|
||||
// Push one MCU block
|
||||
buf.push_mcu_block(&block);
|
||||
assert_eq!(buf.mcu_col, 1);
|
||||
assert_eq!(buf.lines, 0); // Not yet a full MCU row
|
||||
|
||||
// The first 8 pixels of row 0 in row_buf should be 200
|
||||
assert_eq!(buf.row_buf[0], 200);
|
||||
assert_eq!(buf.row_buf[7], 200);
|
||||
// Pixel at column 8 should still be 0
|
||||
assert_eq!(buf.row_buf[8], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mcu_row_flush() {
|
||||
let mut buf = ChannelBuffer::new();
|
||||
let block = [128u8; 64];
|
||||
|
||||
// Fill a complete MCU row (196 blocks)
|
||||
for _ in 0..MCUS_PER_LINE {
|
||||
buf.push_mcu_block(&block);
|
||||
}
|
||||
|
||||
// Should have flushed: 8 lines of LINE_WIDTH pixels
|
||||
assert_eq!(buf.lines, 8);
|
||||
assert_eq!(buf.pixels.len(), 8 * LINE_WIDTH as usize);
|
||||
assert_eq!(buf.mcu_col, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_identify_satellite() {
|
||||
let mut asm = ChannelAssembler::new();
|
||||
assert_eq!(asm.identify_satellite(), None);
|
||||
|
||||
asm.spacecraft_id = Some(SPACECRAFT_M2_3);
|
||||
assert_eq!(asm.identify_satellite(), Some(MeteorSatellite::MeteorM2_3));
|
||||
|
||||
asm.spacecraft_id = Some(SPACECRAFT_M2_4);
|
||||
assert_eq!(asm.identify_satellite(), Some(MeteorSatellite::MeteorM2_4));
|
||||
|
||||
asm.spacecraft_id = Some(99);
|
||||
assert_eq!(asm.identify_satellite(), Some(MeteorSatellite::Unknown));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_magnitude() {
|
||||
assert_eq!(decode_magnitude(0, 0), 0);
|
||||
assert_eq!(decode_magnitude(1, 1), 1);
|
||||
assert_eq!(decode_magnitude(1, 0), -1);
|
||||
assert_eq!(decode_magnitude(2, 3), 3);
|
||||
assert_eq!(decode_magnitude(2, 2), 2);
|
||||
assert_eq!(decode_magnitude(2, 1), -2);
|
||||
assert_eq!(decode_magnitude(2, 0), -3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_idct_dc_only() {
|
||||
// A block with only a DC coefficient should produce a uniform block
|
||||
let mut coeffs = [0i32; 64];
|
||||
coeffs[0] = 100;
|
||||
let mut output = [0u8; 64];
|
||||
idct_8x8(&coeffs, &mut output);
|
||||
|
||||
// All pixels should be close to 128 + 100/4 = 153 (DC is scaled by 1/4)
|
||||
// Actually DC: C(0)*C(0) * coeff * cos(0)*cos(0) / 4
|
||||
// = (1/√2)*(1/√2) * 100 * 1 * 1 / 4 = 100/8 = 12.5, + 128 = 140.5
|
||||
let expected = (100.0_f64 * 0.5 / 4.0 + 128.0).round() as u8;
|
||||
for &px in &output {
|
||||
assert!(
|
||||
(px as i32 - expected as i32).unsigned_abs() <= 1,
|
||||
"pixel {} != expected {}",
|
||||
px,
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bitreader_basics() {
|
||||
let data = [0b10110100, 0b01100000];
|
||||
let mut reader = BitReader::new(&data);
|
||||
|
||||
assert_eq!(reader.read_bit(), Some(1));
|
||||
assert_eq!(reader.read_bit(), Some(0));
|
||||
assert_eq!(reader.read_bit(), Some(1));
|
||||
assert_eq!(reader.read_bit(), Some(1));
|
||||
assert_eq!(reader.read_bits(4), Some(0b0100));
|
||||
assert_eq!(reader.read_bits(3), Some(0b011));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! Meteor-M LRPT (Low Rate Picture Transmission) satellite image decoder.
|
||||
//!
|
||||
//! Decodes the LRPT digital signal broadcast by Meteor-M N2-3 (137.900 MHz)
|
||||
//! and Meteor-M N2-4 (137.100 MHz) using QPSK modulation at 72 kbps.
|
||||
//!
|
||||
//! # Signal chain
|
||||
//!
|
||||
//! The input is baseband IQ or FM-demodulated soft symbols:
|
||||
//! 1. QPSK demodulation with Costas loop carrier recovery.
|
||||
//! 2. Symbol timing recovery (Gardner algorithm).
|
||||
//! 3. CCSDS frame synchronisation (ASM = 0x1ACFFC1D).
|
||||
//! 4. Viterbi decoding (rate 1/2 convolutional code).
|
||||
//! 5. CADU deframing -> VCDU -> MPDU -> APID extraction.
|
||||
//! 6. MCU (Minimum Coded Unit) JPEG decompression per channel.
|
||||
//!
|
||||
//! Active APIDs for Meteor-M imagery:
|
||||
//! - APID 64: channel 1 (visible, 0.5-0.7 um)
|
||||
//! - APID 65: channel 2 (visible/NIR, 0.7-1.1 um)
|
||||
//! - APID 66: channel 3 (near-IR, 1.6-1.8 um)
|
||||
//! - APID 67: channel 4 (mid-IR, 3.5-4.1 um)
|
||||
//! - APID 68: channel 5 (thermal IR, 10.5-11.5 um)
|
||||
//! - APID 69: channel 6 (thermal IR, 11.5-12.5 um)
|
||||
//!
|
||||
//! Call [`LrptDecoder::process_samples`] with each audio/baseband batch,
|
||||
//! then [`LrptDecoder::finalize`] when the pass ends.
|
||||
|
||||
pub mod cadu;
|
||||
pub mod demod;
|
||||
pub mod mcu;
|
||||
|
||||
/// Identified Meteor satellite.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MeteorSatellite {
|
||||
MeteorM2_3,
|
||||
MeteorM2_4,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for MeteorSatellite {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
MeteorSatellite::MeteorM2_3 => write!(f, "Meteor-M N2-3"),
|
||||
MeteorSatellite::MeteorM2_4 => write!(f, "Meteor-M N2-4"),
|
||||
MeteorSatellite::Unknown => write!(f, "Meteor-M (unknown)"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Completed LRPT image returned by [`LrptDecoder::finalize`].
|
||||
pub struct LrptImage {
|
||||
/// PNG-encoded image bytes.
|
||||
pub png: Vec<u8>,
|
||||
/// Number of decoded MCU rows.
|
||||
pub mcu_count: u32,
|
||||
/// Identified satellite, if determinable.
|
||||
pub satellite: Option<MeteorSatellite>,
|
||||
/// Comma-separated APID channels present (e.g. "64,65,66").
|
||||
pub channels: Option<String>,
|
||||
}
|
||||
|
||||
/// Top-level Meteor-M LRPT decoder.
|
||||
///
|
||||
/// Feed baseband samples with [`process_samples`] and call [`finalize`] at
|
||||
/// pass end to retrieve the assembled image.
|
||||
pub struct LrptDecoder {
|
||||
demod: demod::QpskDemod,
|
||||
framer: cadu::CaduFramer,
|
||||
channels: mcu::ChannelAssembler,
|
||||
first_mcu_ms: Option<i64>,
|
||||
}
|
||||
|
||||
impl LrptDecoder {
|
||||
pub fn new(sample_rate: u32) -> Self {
|
||||
Self {
|
||||
demod: demod::QpskDemod::new(sample_rate),
|
||||
framer: cadu::CaduFramer::new(),
|
||||
channels: mcu::ChannelAssembler::new(),
|
||||
first_mcu_ms: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Process a batch of baseband samples.
|
||||
///
|
||||
/// Returns the number of new MCU rows decoded in this batch.
|
||||
pub fn process_samples(&mut self, samples: &[f32]) -> u32 {
|
||||
let before = self.channels.mcu_count();
|
||||
|
||||
// Demodulate to soft symbols
|
||||
let symbols = self.demod.push(samples);
|
||||
|
||||
// Frame sync and CADU extraction
|
||||
let cadus = self.framer.push(&symbols);
|
||||
|
||||
// Decode MCUs from each CADU
|
||||
for cadu in &cadus {
|
||||
self.channels.process_cadu(cadu);
|
||||
}
|
||||
|
||||
let after = self.channels.mcu_count();
|
||||
let new_mcus = after - before;
|
||||
|
||||
if new_mcus > 0 && self.first_mcu_ms.is_none() {
|
||||
self.first_mcu_ms = Some(crate::now_ms());
|
||||
}
|
||||
|
||||
new_mcus
|
||||
}
|
||||
|
||||
/// Total number of MCU rows decoded so far.
|
||||
pub fn mcu_count(&self) -> u32 {
|
||||
self.channels.mcu_count()
|
||||
}
|
||||
|
||||
/// Encode all accumulated channel data as a PNG image.
|
||||
///
|
||||
/// Returns `None` if no MCU rows have been decoded.
|
||||
pub fn finalize(&self) -> Option<LrptImage> {
|
||||
let png = self.channels.encode_png()?;
|
||||
let active_apids = self.channels.active_apids();
|
||||
let channels_str = if active_apids.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
active_apids
|
||||
.iter()
|
||||
.map(|a| a.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(","),
|
||||
)
|
||||
};
|
||||
|
||||
Some(LrptImage {
|
||||
png,
|
||||
mcu_count: self.channels.mcu_count(),
|
||||
satellite: self.channels.identify_satellite(),
|
||||
channels: channels_str,
|
||||
})
|
||||
}
|
||||
|
||||
/// Clear all state; ready to decode a fresh pass.
|
||||
pub fn reset(&mut self) {
|
||||
self.demod.reset();
|
||||
self.framer.reset();
|
||||
self.channels.reset();
|
||||
self.first_mcu_ms = None;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user