use num_complex::Complex;
use rustfft::FftPlanner;
use crate::engine::pipeline::scan_dedup_match;
use crate::engine::{DecodeContext, MessageCodec, 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::Q65Message;
use crate::msg::q65::{ap_hint_to_q65_mask, unpack_symbols_to_bits77};
#[cfg(test)]
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, slot) in row.iter_mut().enumerate() {
let bin = base_bin + (tone + 1) * bins_per_tone;
*slot = 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 Q65Result {
pub message: String,
pub freq_hz: f32,
pub start_sample: usize,
pub dt_sec: f32,
pub iterations: u32,
pub snr_db: f32,
}
fn snr_db_from_sig_noi(xsig: f32, xnoi: f32, bw_offset_db: f32) -> f32 {
const SNR_FLOOR_DB: f32 = -24.0;
const SNR_CEIL_DB: f32 = 49.0;
if xnoi < f32::EPSILON {
return if xsig < f32::EPSILON {
SNR_FLOOR_DB
} else {
SNR_CEIL_DB
};
}
let ratio = xsig / xnoi - 1.0;
if ratio <= 0.001 {
return SNR_FLOOR_DB;
}
(10.0 * ratio.log10() - bw_offset_db).clamp(SNR_FLOOR_DB, SNR_CEIL_DB)
}
fn q65_bw_offset_db<P: ModulationParams>() -> f32 {
10.0 * (2500.0 / P::TONE_SPACING_HZ).log10()
}
fn snr_db_narrow<P: ModulationParams>(energies: &[f32], codeword: &[i32]) -> f32 {
let mut xsig = 0.0f32;
let mut xnoi = 0.0f32;
for (k, &sym) in codeword.iter().enumerate() {
let t = sym as usize;
if t >= 64 || 64 * (k + 1) > energies.len() {
continue;
}
let row = &energies[64 * k..64 * (k + 1)];
let sig = row[t];
let total: f32 = row.iter().sum();
xsig += sig;
xnoi += (total - sig) / 63.0;
}
snr_db_from_sig_noi(xsig, xnoi, q65_bw_offset_db::<P>())
}
fn snr_db_wide<P: ModulationParams>(energies: &[f32], sample_rate: u32, codeword: &[i32]) -> f32 {
let nsps = (sample_rate as f32 * P::SYMBOL_DT).round() as usize;
let df = sample_rate as f32 / nsps as f32;
let bins_per_tone = (P::TONE_SPACING_HZ / df).round().max(1.0) as usize;
let bins_per_symbol = 64 * (2 + bins_per_tone);
let mut xsig = 0.0f32;
let mut xnoi = 0.0f32;
for (k, &sym) in codeword.iter().enumerate() {
let t = sym as usize;
if t >= 64 || bins_per_symbol * (k + 1) > energies.len() {
continue;
}
let row = &energies[bins_per_symbol * k..bins_per_symbol * (k + 1)];
let mut sig = 0.0f32;
let mut total = 0.0f32;
let tail = row.get(64..).unwrap_or(&[]);
for (tone, &v) in tail.iter().step_by(bins_per_tone).take(64).enumerate() {
total += v;
if tone == t {
sig = v;
}
}
xsig += sig;
xnoi += (total - sig) / 63.0;
}
snr_db_from_sig_noi(xsig, xnoi, q65_bw_offset_db::<P>())
}
pub(crate) fn decode_at_for<P: ModulationParams>(
audio: &[f32],
sample_rate: u32,
start_sample: usize,
base_freq_hz: f32,
ctx: &DecodeContext,
) -> Option<Q65Result> {
decode_at_inner::<P>(audio, sample_rate, start_sample, base_freq_hz, None, ctx)
}
pub(crate) fn decode_at_with_ap_for<P: ModulationParams>(
audio: &[f32],
sample_rate: u32,
start_sample: usize,
base_freq_hz: f32,
ap_hint: &ApHint,
ctx: &DecodeContext,
) -> Option<Q65Result> {
decode_at_inner::<P>(
audio,
sample_rate,
start_sample,
base_freq_hz,
Some(ap_hint),
ctx,
)
}
fn decode_at_inner<P: ModulationParams>(
audio: &[f32],
sample_rate: u32,
start_sample: usize,
base_freq_hz: f32,
ap_hint: Option<&ApHint>,
ctx: &DecodeContext,
) -> Option<Q65Result> {
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, ctx)?;
let mut codeword = [0_i32; 63];
codec.encode(&info_syms, &mut codeword);
let fallback = snr_db_narrow::<P>(&energies, &codeword);
let snr_db = super::snr::q65_snr_db::<P>(
audio,
sample_rate,
start_sample,
base_freq_hz,
&codeword,
fallback,
);
Some(Q65Result {
message: text,
freq_hz: base_freq_hz,
start_sample,
dt_sec: start_sample as f32 / sample_rate as f32,
iterations,
snr_db,
})
}
pub(crate) 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>,
ctx: &DecodeContext,
) -> Option<Q65Result> {
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, ctx)?;
let mut codeword = [0_i32; 63];
codec.encode(&info_syms, &mut codeword);
let fallback = snr_db_wide::<P>(&energies, sample_rate, &codeword);
let snr_db = super::snr::q65_snr_db::<P>(
audio,
sample_rate,
start_sample,
base_freq_hz,
&codeword,
fallback,
);
Some(Q65Result {
message: text,
freq_hz: base_freq_hz,
start_sample,
dt_sec: start_sample as f32 / sample_rate as f32,
iterations,
snr_db,
})
}
#[allow(clippy::too_many_arguments)]
pub(crate) 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>,
on_result: Option<&(dyn Fn(&Q65Result) + Sync)>,
ctx: &DecodeContext,
) -> Vec<Q65Result> {
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<Q65Result> = 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,
ctx,
) else {
continue;
};
let dup = scan_dedup_match(
&seen,
&decode,
|r| &r.message,
|r| r.freq_hz,
|r| r.start_sample as i64,
dedup_freq_tol_hz::<P>(),
nsps as i64,
);
if !dup {
if let Some(cb) = on_result {
cb(&decode);
}
seen.push(decode);
}
}
seen
}
pub(crate) fn decode_at_with_ap_list_for<P: ModulationParams>(
audio: &[f32],
sample_rate: u32,
start_sample: usize,
base_freq_hz: f32,
candidates: &[[i32; 63]],
ctx: &DecodeContext,
) -> Option<Q65Result> {
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, ctx)?;
let fallback = snr_db_narrow::<P>(&energies, &candidates[idx]);
let snr_db = super::snr::q65_snr_db::<P>(
audio,
sample_rate,
start_sample,
base_freq_hz,
&candidates[idx],
fallback,
);
Some(Q65Result {
message: text,
freq_hz: base_freq_hz,
start_sample,
dt_sec: start_sample as f32 / sample_rate as f32,
iterations: 0,
snr_db,
})
}
#[allow(clippy::too_many_arguments)]
pub(crate) 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]],
on_result: Option<&(dyn Fn(&Q65Result) + Sync)>,
ctx: &DecodeContext,
) -> Vec<Q65Result> {
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<Q65Result> = 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,
ctx,
) else {
continue;
};
let dup = scan_dedup_match(
&seen,
&decode,
|r| &r.message,
|r| r.freq_hz,
|r| r.start_sample as i64,
dedup_freq_tol_hz::<P>(),
nsps as i64,
);
if !dup {
if let Some(cb) = on_result {
cb(&decode);
}
seen.push(decode);
}
}
seen
}
fn dedup_freq_tol_hz<P: ModulationParams>() -> f32 {
(2.0 * P::TONE_SPACING_HZ).max(4.0)
}
pub(crate) fn decode_scan_for<P: ModulationParams>(
audio: &[f32],
sample_rate: u32,
nominal_start_sample: usize,
params: &super::search::SearchParams,
on_result: Option<&(dyn Fn(&Q65Result) + Sync)>,
ctx: &DecodeContext,
) -> Vec<Q65Result> {
decode_scan_inner::<P>(
audio,
sample_rate,
nominal_start_sample,
params,
None,
on_result,
ctx,
)
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn decode_scan_with_ap_for<P: ModulationParams>(
audio: &[f32],
sample_rate: u32,
nominal_start_sample: usize,
params: &super::search::SearchParams,
ap_hint: &ApHint,
on_result: Option<&(dyn Fn(&Q65Result) + Sync)>,
ctx: &DecodeContext,
) -> Vec<Q65Result> {
decode_scan_inner::<P>(
audio,
sample_rate,
nominal_start_sample,
params,
Some(ap_hint),
on_result,
ctx,
)
}
#[allow(clippy::too_many_arguments)]
fn decode_scan_inner<P: ModulationParams>(
audio: &[f32],
sample_rate: u32,
nominal_start_sample: usize,
params: &super::search::SearchParams,
ap_hint: Option<&ApHint>,
on_result: Option<&(dyn Fn(&Q65Result) + Sync)>,
ctx: &DecodeContext,
) -> Vec<Q65Result> {
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<Q65Result> = Vec::new();
for c in cands {
let Some(decode) = decode_at_with_fine_timing_for::<P>(
audio,
sample_rate,
c.start_sample,
c.freq_hz,
nsps,
ap_hint,
ctx,
) else {
continue;
};
let dup = scan_dedup_match(
&seen,
&decode,
|r| &r.message,
|r| r.freq_hz,
|r| r.start_sample as i64,
dedup_freq_tol_hz::<P>(),
nsps as i64,
);
if !dup {
if let Some(cb) = on_result {
cb(&decode);
}
seen.push(decode);
}
}
seen
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GridDepth {
Fast,
Normal,
Deep,
}
impl GridDepth {
fn params(self) -> (i32, i32, i32) {
match self {
GridDepth::Fast => (1, 1, 4),
GridDepth::Normal => (3, 3, 5),
GridDepth::Deep => (5, 5, 5),
}
}
}
fn ibwa_for_submode(submode: u8) -> i32 {
match submode {
0 => 1, 1 => 3, _ => 8, }
}
fn zigzag_offset(idx1: i32) -> i32 {
let n = idx1 / 2;
if idx1 % 2 == 0 { -n } else { n }
}
#[allow(clippy::too_many_arguments)]
fn decode_at_grid_for<P: ModulationParams>(
audio: &[f32],
sample_rate: u32,
start_sample: usize,
base_freq_hz: f32,
depth: GridDepth,
ap_hint: Option<&ApHint>,
ctx: &DecodeContext,
) -> Option<Q65Result> {
let nsps = (sample_rate as f32 * P::SYMBOL_DT).round() as usize;
let baud = 1.0 / P::SYMBOL_DT;
let dt_step = (nsps / 16).max(1) as i64;
let (idfmax, idtmax, maxdist) = depth.params();
let submode = submode_index_from_params::<P>();
let ibwa = ibwa_for_submode(submode);
let ibwb = (ibwa + 6).min(15);
let ibw0 = (ibwa + ibwb) / 2;
let mut codec = Q65Codec::new(&QRA15_65_64_IRR_E23);
let mut info_syms = [0_i32; 13];
let mut intrinsics = vec![0.0_f32; 64 * 63];
let es_no = default_es_no_metric();
for idf in 1..=idfmax {
let ndf = zigzag_offset(idf);
let freq_shift = base_freq_hz + 0.5 * baud * ndf as f32;
for idt in 1..=idtmax {
let ndt = zigzag_offset(idt);
let ndist_ft = ndf * ndf + ndt * ndt;
if ndist_ft > maxdist {
continue;
}
let dt_offset = ndt as i64 * dt_step;
let Ok(shifted_start) = usize::try_from(start_sample as i64 + dt_offset) else {
continue;
};
let Some(energies) =
extract_data_energies_wide::<P>(audio, sample_rate, shifted_start, freq_shift)
else {
continue;
};
for ibw in ibwa..=ibwb {
let ndist = ndist_ft + (ibw - ibw0) * (ibw - ibw0);
if (ndf != 0 || ndt != 0) && ndist > maxdist {
continue;
}
let b90 = 1.72_f32.powi(ibw);
if (ndf != 0 || ndt != 0) && b90 > 345.0 {
continue;
}
let b90_ts = b90 / baud;
let _state = intrinsics_fast_fading(
&QRA15_65_64_IRR_E23,
&mut intrinsics,
&energies,
submode,
b90_ts,
FadingModel::Lorentzian,
es_no,
);
let result = 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)
}
_ => codec.decode(&intrinsics, &mut info_syms, 50),
};
let Ok(iterations) = result else { continue };
let bits77 = unpack_symbols_to_bits77(&info_syms);
let Some(text) = Q65Message.unpack(&bits77, ctx) else {
continue;
};
let mut codeword = [0_i32; 63];
codec.encode(&info_syms, &mut codeword);
let fallback = snr_db_wide::<P>(&energies, sample_rate, &codeword);
let snr_db = super::snr::q65_snr_db::<P>(
audio,
sample_rate,
shifted_start,
freq_shift,
&codeword,
fallback,
);
return Some(Q65Result {
message: text,
freq_hz: freq_shift,
start_sample: shifted_start,
dt_sec: shifted_start as f32 / sample_rate as f32,
iterations,
snr_db,
});
}
}
}
None
}
#[allow(clippy::too_many_arguments)]
fn decode_at_with_fine_timing_for<P: ModulationParams>(
audio: &[f32],
sample_rate: u32,
start_sample: usize,
freq_hz: f32,
_nsps: usize,
ap_hint: Option<&ApHint>,
ctx: &DecodeContext,
) -> Option<Q65Result> {
decode_at_grid_for::<P>(
audio,
sample_rate,
start_sample,
freq_hz,
GridDepth::Fast,
ap_hint,
ctx,
)
}
fn averaged_data_energies<P: ModulationParams>(
audio_slots: &[&[f32]],
sample_rate: u32,
start_sample: usize,
base_freq_hz: f32,
) -> Option<Vec<f32>> {
let mut accum: Option<Vec<f32>> = None;
let mut count = 0_usize;
for &audio in audio_slots {
let Some(e) = extract_data_energies::<P>(audio, sample_rate, start_sample, base_freq_hz)
else {
continue;
};
match accum.as_mut() {
Some(a) => {
for (slot, v) in a.iter_mut().zip(&e) {
*slot += *v;
}
}
None => accum = Some(e),
}
count += 1;
}
let mut accum = accum?;
if count > 1 {
let inv = 1.0_f32 / count as f32;
for v in &mut accum {
*v *= inv;
}
}
Some(accum)
}
fn averaged_data_energies_wide<P: ModulationParams>(
audio_slots: &[&[f32]],
sample_rate: u32,
start_sample: usize,
base_freq_hz: f32,
) -> Option<Vec<f32>> {
let mut accum: Option<Vec<f32>> = None;
let mut count = 0_usize;
for &audio in audio_slots {
let Some(e) =
extract_data_energies_wide::<P>(audio, sample_rate, start_sample, base_freq_hz)
else {
continue;
};
match accum.as_mut() {
Some(a) => {
for (slot, v) in a.iter_mut().zip(&e) {
*slot += *v;
}
}
None => accum = Some(e),
}
count += 1;
}
let mut accum = accum?;
if count > 1 {
let inv = 1.0_f32 / count as f32;
for v in &mut accum {
*v *= inv;
}
}
Some(accum)
}
#[allow(clippy::too_many_arguments)]
fn decode_averaged_ap_list_for<P: ModulationParams>(
energies: &[f32],
history: &[&[f32]],
sample_rate: u32,
start_sample: usize,
base_freq_hz: f32,
candidates: &[[i32; 63]],
ctx: &DecodeContext,
) -> Option<Q65Result> {
if candidates.is_empty() {
return None;
}
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, ctx)?;
let fallback = snr_db_narrow::<P>(energies, &candidates[idx]);
let snr_db = super::snr::q65_snr_db_averaged::<P>(
history,
sample_rate,
start_sample,
base_freq_hz,
&candidates[idx],
fallback,
);
Some(Q65Result {
message: text,
freq_hz: base_freq_hz,
start_sample,
dt_sec: start_sample as f32 / sample_rate as f32,
iterations: 0,
snr_db,
})
}
#[allow(clippy::too_many_arguments)]
fn decode_fading_with_energies<P: ModulationParams>(
energies: &[f32],
history: &[&[f32]],
sample_rate: u32,
start_sample: usize,
base_freq_hz: f32,
b90_ts: f32,
model: FadingModel,
ctx: &DecodeContext,
) -> Option<Q65Result> {
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 = codec.decode(&intrinsics, &mut info_syms, 50).ok()?;
let bits77 = unpack_symbols_to_bits77(&info_syms);
let text = Q65Message.unpack(&bits77, ctx)?;
let mut codeword = [0_i32; 63];
codec.encode(&info_syms, &mut codeword);
let fallback = snr_db_wide::<P>(energies, sample_rate, &codeword);
let snr_db = super::snr::q65_snr_db_averaged::<P>(
history,
sample_rate,
start_sample,
base_freq_hz,
&codeword,
fallback,
);
Some(Q65Result {
message: text,
freq_hz: base_freq_hz,
start_sample,
dt_sec: start_sample as f32 / sample_rate as f32,
iterations,
snr_db,
})
}
fn decode_averaged_plain_for<P: ModulationParams>(
energies: &[f32],
history: &[&[f32]],
sample_rate: u32,
base_freq_hz: f32,
start_sample: usize,
ctx: &DecodeContext,
) -> Option<Q65Result> {
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 = codec.decode(&intrinsics, &mut info_syms, 50).ok()?;
let bits77 = unpack_symbols_to_bits77(&info_syms);
let text = Q65Message.unpack(&bits77, ctx)?;
let mut codeword = [0_i32; 63];
codec.encode(&info_syms, &mut codeword);
let fallback = snr_db_narrow::<P>(energies, &codeword);
let snr_db = super::snr::q65_snr_db_averaged::<P>(
history,
sample_rate,
start_sample,
base_freq_hz,
&codeword,
fallback,
);
Some(Q65Result {
message: text,
freq_hz: base_freq_hz,
start_sample,
dt_sec: start_sample as f32 / sample_rate as f32,
iterations,
snr_db,
})
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn decode_multi_period_for<P: ModulationParams>(
audio_slots: &[&[f32]],
sample_rate: u32,
nominal_start_sample: usize,
params: &super::search::SearchParams,
ap_codewords: Option<&[[i32; 63]]>,
on_result: Option<&(dyn Fn(&Q65Result) + Sync)>,
ctx: &DecodeContext,
) -> Vec<Q65Result> {
use super::search::{build_spectrogram, coarse_search_on_spec_for};
let mut output: Vec<Q65Result> = Vec::new();
if audio_slots.is_empty() {
return output;
}
let mut ema_spec = build_spectrogram::<P>(audio_slots[0], sample_rate);
if ema_spec.n_time == 0 {
return output;
}
let b90_ladder = [3.0_f32, 8.0, 15.0];
let fading_models = [FadingModel::Gaussian, FadingModel::Lorentzian];
for (i, &audio) in audio_slots.iter().enumerate() {
if i > 0 {
let slot_spec = build_spectrogram::<P>(audio, sample_rate);
if slot_spec.n_time == ema_spec.n_time
&& slot_spec.n_freq == ema_spec.n_freq
&& slot_spec.mags_sqr.len() == ema_spec.mags_sqr.len()
{
let weight = 1.0_f32 / ((i + 1).min(4) as f32);
let one_minus = 1.0 - weight;
for (e, s) in ema_spec.mags_sqr.iter_mut().zip(&slot_spec.mags_sqr) {
*e = weight * *s + one_minus * *e;
}
ema_spec.noise_per_bin =
weight * slot_spec.noise_per_bin + one_minus * ema_spec.noise_per_bin;
}
}
let candidates =
coarse_search_on_spec_for::<P>(&ema_spec, sample_rate, nominal_start_sample, params);
let history = &audio_slots[..=i];
let mut slot_decode: Option<Q65Result> = None;
'candidate_loop: for cand in candidates {
let mut energies_narrow: Option<Option<Vec<f32>>> = None;
if let Some(codewords) = ap_codewords
&& let Some(energies) = energies_narrow
.get_or_insert_with(|| {
averaged_data_energies::<P>(
history,
sample_rate,
cand.start_sample,
cand.freq_hz,
)
})
.as_deref()
&& let Some(d) = decode_averaged_ap_list_for::<P>(
energies,
history,
sample_rate,
cand.start_sample,
cand.freq_hz,
codewords,
ctx,
)
{
slot_decode = Some(d);
break 'candidate_loop;
}
if let Some(energies) = averaged_data_energies_wide::<P>(
history,
sample_rate,
cand.start_sample,
cand.freq_hz,
) {
for &b90 in &b90_ladder {
for &model in &fading_models {
if let Some(d) = decode_fading_with_energies::<P>(
&energies,
history,
sample_rate,
cand.start_sample,
cand.freq_hz,
b90,
model,
ctx,
) {
slot_decode = Some(d);
break 'candidate_loop;
}
}
}
}
if let Some(energies) = energies_narrow
.get_or_insert_with(|| {
averaged_data_energies::<P>(
history,
sample_rate,
cand.start_sample,
cand.freq_hz,
)
})
.as_deref()
&& let Some(d) = decode_averaged_plain_for::<P>(
energies,
history,
sample_rate,
cand.freq_hz,
cand.start_sample,
ctx,
)
{
slot_decode = Some(d);
break 'candidate_loop;
}
}
if let Some(d) = slot_decode {
let dup = output
.iter()
.any(|prev| prev.message == d.message && (prev.freq_hz - d.freq_hz).abs() <= 4.0);
if !dup {
if let Some(cb) = on_result {
cb(&d);
}
output.push(d);
}
}
}
output
}
#[cfg(test)]
mod tests {
use super::super::decode_request::DecodeRequest;
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 = DecodeRequest::<Q65a30>::sniper(&audio, 12_000, 0, freq)
.decode()
.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 =
DecodeRequest::<Q65a30>::new(&audio, 12_000, 0, super::super::SearchParams::default())
.decode();
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 =
DecodeRequest::<Q65a30>::new(&audio, 12_000, 0, super::super::SearchParams::default())
.decode();
assert!(
decodes.is_empty(),
"got false decodes from silence: {decodes:#?}"
);
}
#[test]
fn sniper_hash_table_resolves_hashed_callsign() {
use super::super::tx::{encode_channel_symbols, synthesize_audio_for};
use crate::msg::hash_table::CallsignHashTable;
use crate::msg::wsjt77::pack77_type4;
use alloc::sync::Arc;
let bits77 = pack77_type4("JL1NIE/1", "JA1ABC", "", false).expect("pack77_type4 failed");
let tones = encode_channel_symbols(&bits77);
let freq = 1500.0;
let audio = synthesize_audio_for::<Q65a30>(&tones, 12_000, freq, 0.3);
let no_ht = DecodeRequest::<Q65a30>::sniper(&audio, 12_000, 0, freq)
.decode()
.expect("clean aligned decode must succeed");
assert!(
no_ht.message.contains("JL1NIE/1") && no_ht.message.contains("<...>"),
"expected an unresolved '<...>' decode without a hash table: {no_ht:?}"
);
let mut ht = CallsignHashTable::new();
ht.insert("JA1ABC");
let with_ht = DecodeRequest::<Q65a30>::sniper(&audio, 12_000, 0, freq)
.hash_table(Arc::new(ht))
.decode()
.expect("clean aligned decode must succeed");
assert!(
with_ht.message.contains("JL1NIE/1") && with_ht.message.contains("<JA1ABC>"),
"expected the hashed callsign to resolve via the supplied table: {with_ht:?}"
);
}
}