use num_complex::Complex;
use rustfft::FftPlanner;
use crate::core::ModulationParams;
use crate::fec::qra::{FadingModel, Q65Codec, intrinsics_fast_fading};
use crate::fec::qra15_65_64::QRA15_65_64_IRR_E23;
use crate::msg::ApHint;
use crate::msg::q65::{ap_hint_to_q65_mask, unpack_symbols_to_bits77};
use super::Q65a30;
use super::sync_pattern::Q65_SYNC_POSITIONS;
fn default_es_no_metric() -> f32 {
let eb_no_db = 2.8_f32;
let eb_no = 10.0_f32.powf(eb_no_db / 10.0);
let nm = 6.0_f32;
let rate = 15.0 / 65.0;
nm * rate * eb_no
}
fn extract_data_energies<P: ModulationParams>(
audio: &[f32],
sample_rate: u32,
start_sample: usize,
base_freq_hz: f32,
) -> Option<Vec<f32>> {
let nsps = (sample_rate as f32 * P::SYMBOL_DT).round() as usize;
let df = sample_rate as f32 / nsps as f32;
let base_bin = (base_freq_hz / df).round() as usize;
let bins_per_tone = (P::TONE_SPACING_HZ / df).round() as usize;
let highest_bin = base_bin + 64 * bins_per_tone;
if start_sample + 85 * nsps > audio.len() || highest_bin >= nsps / 2 {
return None;
}
let mut planner = FftPlanner::<f32>::new();
let fft = planner.plan_fft_forward(nsps);
let mut scratch = vec![Complex::new(0f32, 0f32); fft.get_inplace_scratch_len()];
let mut buf: Vec<Complex<f32>> = vec![Complex::new(0f32, 0f32); nsps];
let mut energies = vec![0.0_f32; 64 * 63];
let mut sync_iter = Q65_SYNC_POSITIONS.iter().peekable();
let mut k = 0usize;
for sym_idx in 0..85u32 {
if sync_iter.peek().is_some_and(|&&p| p == sym_idx) {
sync_iter.next();
continue;
}
let sym_start = start_sample + sym_idx as usize * nsps;
for (slot, &s) in buf.iter_mut().zip(&audio[sym_start..sym_start + nsps]) {
*slot = Complex::new(s, 0.0);
}
fft.process_with_scratch(&mut buf, &mut scratch);
let row = &mut energies[64 * k..64 * (k + 1)];
for tone in 0..64 {
let bin = base_bin + (tone + 1) * bins_per_tone;
row[tone] = buf[bin].norm_sqr();
}
k += 1;
}
debug_assert_eq!(k, 63);
Some(energies)
}
fn extract_data_energies_wide<P: ModulationParams>(
audio: &[f32],
sample_rate: u32,
start_sample: usize,
base_freq_hz: f32,
) -> Option<Vec<f32>> {
let nsps = (sample_rate as f32 * P::SYMBOL_DT).round() as usize;
let df = sample_rate as f32 / nsps as f32;
let base_bin = (base_freq_hz / df).round() as usize;
let bins_per_tone = (P::TONE_SPACING_HZ / df).round() as usize;
if base_bin + bins_per_tone < 64 {
return None;
}
let central_data_tone0 = base_bin + bins_per_tone;
let wide_start = central_data_tone0 - 64; let bins_per_symbol = 64 * (2 + bins_per_tone);
let wide_end_exclusive = wide_start + bins_per_symbol;
if start_sample + 85 * nsps > audio.len() || wide_end_exclusive > nsps / 2 {
return None;
}
let mut planner = FftPlanner::<f32>::new();
let fft = planner.plan_fft_forward(nsps);
let mut scratch = vec![Complex::new(0f32, 0f32); fft.get_inplace_scratch_len()];
let mut buf: Vec<Complex<f32>> = vec![Complex::new(0f32, 0f32); nsps];
let mut energies = vec![0.0_f32; bins_per_symbol * 63];
let mut sync_iter = Q65_SYNC_POSITIONS.iter().peekable();
let mut k = 0usize;
for sym_idx in 0..85u32 {
if sync_iter.peek().is_some_and(|&&p| p == sym_idx) {
sync_iter.next();
continue;
}
let sym_start = start_sample + sym_idx as usize * nsps;
for (slot, &s) in buf.iter_mut().zip(&audio[sym_start..sym_start + nsps]) {
*slot = Complex::new(s, 0.0);
}
fft.process_with_scratch(&mut buf, &mut scratch);
let row = &mut energies[bins_per_symbol * k..bins_per_symbol * (k + 1)];
for (i, slot) in row.iter_mut().enumerate() {
*slot = buf[wide_start + i].norm_sqr();
}
k += 1;
}
debug_assert_eq!(k, 63);
Some(energies)
}
fn submode_index_from_params<P: ModulationParams>() -> u8 {
let bpt = (P::TONE_SPACING_HZ / (12_000.0 / P::NSPS as f32)).round() as u32;
bpt.trailing_zeros() as u8
}
#[derive(Clone, Debug)]
pub struct Q65Decode {
pub message: String,
pub freq_hz: f32,
pub start_sample: usize,
pub iterations: u32,
}
pub fn decode_at_for<P: ModulationParams>(
audio: &[f32],
sample_rate: u32,
start_sample: usize,
base_freq_hz: f32,
) -> Option<Q65Decode> {
decode_at_inner::<P>(audio, sample_rate, start_sample, base_freq_hz, None)
}
pub fn decode_at_with_ap_for<P: ModulationParams>(
audio: &[f32],
sample_rate: u32,
start_sample: usize,
base_freq_hz: f32,
ap_hint: &ApHint,
) -> Option<Q65Decode> {
decode_at_inner::<P>(
audio,
sample_rate,
start_sample,
base_freq_hz,
Some(ap_hint),
)
}
fn decode_at_inner<P: ModulationParams>(
audio: &[f32],
sample_rate: u32,
start_sample: usize,
base_freq_hz: f32,
ap_hint: Option<&ApHint>,
) -> Option<Q65Decode> {
use crate::core::{DecodeContext, MessageCodec};
use crate::msg::Q65Message;
let energies = extract_data_energies::<P>(audio, sample_rate, start_sample, base_freq_hz)?;
let mut intrinsics = vec![0.0_f32; 64 * 63];
QRA15_65_64_IRR_E23.mfsk_bessel_metric(&mut intrinsics, &energies, 63, default_es_no_metric());
let mut codec = Q65Codec::new(&QRA15_65_64_IRR_E23);
let mut info_syms = [0_i32; 13];
let iterations = match ap_hint {
Some(hint) if hint.has_info() => {
let (mask, syms) = ap_hint_to_q65_mask(hint);
codec
.decode_with_ap(&intrinsics, &mut info_syms, 50, &mask, &syms)
.ok()?
}
_ => codec.decode(&intrinsics, &mut info_syms, 50).ok()?,
};
let bits77 = unpack_symbols_to_bits77(&info_syms);
let text = Q65Message.unpack(&bits77, &DecodeContext::default())?;
Some(Q65Decode {
message: text,
freq_hz: base_freq_hz,
start_sample,
iterations,
})
}
pub fn decode_at_fading_for<P: ModulationParams>(
audio: &[f32],
sample_rate: u32,
start_sample: usize,
base_freq_hz: f32,
b90_ts: f32,
model: FadingModel,
ap_hint: Option<&ApHint>,
) -> Option<Q65Decode> {
use crate::core::{DecodeContext, MessageCodec};
use crate::msg::Q65Message;
let energies = extract_data_energies_wide::<P>(audio, sample_rate, start_sample, base_freq_hz)?;
let mut intrinsics = vec![0.0_f32; 64 * 63];
let _state = intrinsics_fast_fading(
&QRA15_65_64_IRR_E23,
&mut intrinsics,
&energies,
submode_index_from_params::<P>(),
b90_ts,
model,
default_es_no_metric(),
);
let mut codec = Q65Codec::new(&QRA15_65_64_IRR_E23);
let mut info_syms = [0_i32; 13];
let iterations = match ap_hint {
Some(hint) if hint.has_info() => {
let (mask, syms) = ap_hint_to_q65_mask(hint);
codec
.decode_with_ap(&intrinsics, &mut info_syms, 50, &mask, &syms)
.ok()?
}
_ => codec.decode(&intrinsics, &mut info_syms, 50).ok()?,
};
let bits77 = unpack_symbols_to_bits77(&info_syms);
let text = Q65Message.unpack(&bits77, &DecodeContext::default())?;
Some(Q65Decode {
message: text,
freq_hz: base_freq_hz,
start_sample,
iterations,
})
}
pub fn decode_scan_fading_for<P: ModulationParams>(
audio: &[f32],
sample_rate: u32,
nominal_start_sample: usize,
params: &super::search::SearchParams,
b90_ts: f32,
model: FadingModel,
ap_hint: Option<&ApHint>,
) -> Vec<Q65Decode> {
let nsps = (sample_rate as f32 * P::SYMBOL_DT).round() as usize;
let cands =
super::search::coarse_search_for::<P>(audio, sample_rate, nominal_start_sample, params);
let mut seen: Vec<Q65Decode> = Vec::new();
for c in cands {
let Some(decode) = decode_at_fading_for::<P>(
audio,
sample_rate,
c.start_sample,
c.freq_hz,
b90_ts,
model,
ap_hint,
) else {
continue;
};
let dup = seen.iter().any(|prev| {
prev.message == decode.message
&& (prev.freq_hz - decode.freq_hz).abs() <= 4.0
&& (prev.start_sample as i64 - decode.start_sample as i64).abs() <= nsps as i64
});
if !dup {
seen.push(decode);
}
}
seen
}
pub fn decode_at_with_ap_list_for<P: ModulationParams>(
audio: &[f32],
sample_rate: u32,
start_sample: usize,
base_freq_hz: f32,
candidates: &[[i32; 63]],
) -> Option<Q65Decode> {
use crate::core::{DecodeContext, MessageCodec};
use crate::msg::Q65Message;
if candidates.is_empty() {
return None;
}
let energies = extract_data_energies::<P>(audio, sample_rate, start_sample, base_freq_hz)?;
let mut intrinsics = vec![0.0_f32; 64 * 63];
QRA15_65_64_IRR_E23.mfsk_bessel_metric(&mut intrinsics, &energies, 63, default_es_no_metric());
let codec = Q65Codec::new(&QRA15_65_64_IRR_E23);
let (_idx, info_syms) = codec.decode_with_codeword_list(&intrinsics, candidates)?;
let bits77 = unpack_symbols_to_bits77(&info_syms);
let text = Q65Message.unpack(&bits77, &DecodeContext::default())?;
Some(Q65Decode {
message: text,
freq_hz: base_freq_hz,
start_sample,
iterations: 0,
})
}
pub fn decode_scan_with_ap_list_for<P: ModulationParams>(
audio: &[f32],
sample_rate: u32,
nominal_start_sample: usize,
params: &super::search::SearchParams,
candidates: &[[i32; 63]],
) -> Vec<Q65Decode> {
if candidates.is_empty() {
return Vec::new();
}
let nsps = (sample_rate as f32 * P::SYMBOL_DT).round() as usize;
let cands =
super::search::coarse_search_for::<P>(audio, sample_rate, nominal_start_sample, params);
let mut seen: Vec<Q65Decode> = Vec::new();
for c in cands {
let Some(decode) = decode_at_with_ap_list_for::<P>(
audio,
sample_rate,
c.start_sample,
c.freq_hz,
candidates,
) else {
continue;
};
let dup = seen.iter().any(|prev| {
prev.message == decode.message
&& (prev.freq_hz - decode.freq_hz).abs() <= 4.0
&& (prev.start_sample as i64 - decode.start_sample as i64).abs() <= nsps as i64
});
if !dup {
seen.push(decode);
}
}
seen
}
pub fn decode_at(
audio: &[f32],
sample_rate: u32,
start_sample: usize,
base_freq_hz: f32,
) -> Option<Q65Decode> {
decode_at_for::<Q65a30>(audio, sample_rate, start_sample, base_freq_hz)
}
pub fn decode_at_with_ap(
audio: &[f32],
sample_rate: u32,
start_sample: usize,
base_freq_hz: f32,
ap_hint: &ApHint,
) -> Option<Q65Decode> {
decode_at_with_ap_for::<Q65a30>(audio, sample_rate, start_sample, base_freq_hz, ap_hint)
}
pub fn decode_scan_for<P: ModulationParams>(
audio: &[f32],
sample_rate: u32,
nominal_start_sample: usize,
params: &super::search::SearchParams,
) -> Vec<Q65Decode> {
decode_scan_inner::<P>(audio, sample_rate, nominal_start_sample, params, None)
}
pub fn decode_scan_with_ap_for<P: ModulationParams>(
audio: &[f32],
sample_rate: u32,
nominal_start_sample: usize,
params: &super::search::SearchParams,
ap_hint: &ApHint,
) -> Vec<Q65Decode> {
decode_scan_inner::<P>(
audio,
sample_rate,
nominal_start_sample,
params,
Some(ap_hint),
)
}
fn decode_scan_inner<P: ModulationParams>(
audio: &[f32],
sample_rate: u32,
nominal_start_sample: usize,
params: &super::search::SearchParams,
ap_hint: Option<&ApHint>,
) -> Vec<Q65Decode> {
let nsps = (sample_rate as f32 * P::SYMBOL_DT).round() as usize;
let cands =
super::search::coarse_search_for::<P>(audio, sample_rate, nominal_start_sample, params);
let mut seen: Vec<Q65Decode> = Vec::new();
for c in cands {
let decode = match ap_hint {
Some(hint) if hint.has_info() => {
decode_at_with_ap_for::<P>(audio, sample_rate, c.start_sample, c.freq_hz, hint)
}
_ => decode_at_for::<P>(audio, sample_rate, c.start_sample, c.freq_hz),
};
let Some(decode) = decode else {
continue;
};
let dup = seen.iter().any(|prev| {
prev.message == decode.message
&& (prev.freq_hz - decode.freq_hz).abs() <= 4.0
&& (prev.start_sample as i64 - decode.start_sample as i64).abs() <= nsps as i64
});
if !dup {
seen.push(decode);
}
}
seen
}
pub fn decode_scan(
audio: &[f32],
sample_rate: u32,
nominal_start_sample: usize,
params: &super::search::SearchParams,
) -> Vec<Q65Decode> {
decode_scan_for::<Q65a30>(audio, sample_rate, nominal_start_sample, params)
}
pub fn decode_scan_with_ap(
audio: &[f32],
sample_rate: u32,
nominal_start_sample: usize,
params: &super::search::SearchParams,
ap_hint: &ApHint,
) -> Vec<Q65Decode> {
decode_scan_with_ap_for::<Q65a30>(audio, sample_rate, nominal_start_sample, params, ap_hint)
}
pub fn decode_scan_default(audio: &[f32], sample_rate: u32) -> Vec<Q65Decode> {
decode_scan(
audio,
sample_rate,
0,
&super::search::SearchParams::default(),
)
}
#[cfg(test)]
mod tests {
use super::super::tx::synthesize_standard;
use super::*;
#[test]
fn aligned_decode_recovers_clean_message() {
let freq = 1500.0;
let audio =
synthesize_standard("CQ", "K1ABC", "FN42", 12_000, freq, 0.3).expect("pack + synth");
let result = decode_at(&audio, 12_000, 0, freq).expect("clean aligned decode must succeed");
assert_eq!(result.message, "CQ K1ABC FN42");
assert_eq!(result.start_sample, 0);
assert!((result.freq_hz - freq).abs() < 0.001);
}
#[test]
fn scan_recovers_clean_message_without_alignment_hint() {
let freq = 1500.0;
let audio =
synthesize_standard("CQ", "JA1ABC", "PM95", 12_000, freq, 0.3).expect("pack + synth");
let decodes = decode_scan_default(&audio, 12_000);
assert!(!decodes.is_empty(), "scan must find a clean signal");
assert_eq!(decodes[0].message, "CQ JA1ABC PM95");
}
#[test]
fn scan_with_no_signal_returns_empty() {
let audio = vec![0.0_f32; 12_000 * 30];
let decodes = decode_scan_default(&audio, 12_000);
assert!(
decodes.is_empty(),
"got false decodes from silence: {decodes:#?}"
);
}
}