ab8425c85c
Move ft2_encode from ft4/ to ft2/ where it belongs. Remove all module-level #[allow] suppressions and fix the underlying issues: - Remove dead code: wf_mag_at, xor_rows, unused Monitor IFFT fields, OsdBox.size - Gate encode174_to_bits with #[cfg(test)] (only used in tests) - Convert 40+ C-style index loops to idiomatic iterators - Add targeted #[allow(clippy::too_many_arguments)] on two OSD functions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Stan Grams <sjg@haxx.space>
847 lines
25 KiB
Rust
847 lines
25 KiB
Rust
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
//
|
|
// SPDX-License-Identifier: BSD-2-Clause
|
|
|
|
//! 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);
|
|
}
|
|
}
|