use num_complex::Complex;
use rustfft::FftPlanner;
use crate::engine::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};
#[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 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 Q65Result {
pub message: String,
pub freq_hz: f32,
pub start_sample: usize,
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;
for tone in 0..64usize {
let idx = 64 + tone * bins_per_tone;
if idx >= row.len() {
continue;
}
let v = row[idx];
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,
) -> Option<Q65Result> {
decode_at_inner::<P>(audio, sample_rate, start_sample, base_freq_hz, None)
}
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,
) -> Option<Q65Result> {
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<Q65Result> {
use crate::engine::{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())?;
let mut codeword = [0_i32; 63];
codec.encode(&info_syms, &mut codeword);
let snr_db = snr_db_narrow::<P>(&energies, &codeword);
Some(Q65Result {
message: text,
freq_hz: base_freq_hz,
start_sample,
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>,
) -> Option<Q65Result> {
use crate::engine::{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())?;
let mut codeword = [0_i32; 63];
codec.encode(&info_syms, &mut codeword);
let snr_db = snr_db_wide::<P>(&energies, sample_rate, &codeword);
Some(Q65Result {
message: text,
freq_hz: base_freq_hz,
start_sample,
iterations,
snr_db,
})
}
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>,
) -> 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,
) 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(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]],
) -> Option<Q65Result> {
use crate::engine::{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())?;
let snr_db = snr_db_narrow::<P>(&energies, &candidates[idx]);
Some(Q65Result {
message: text,
freq_hz: base_freq_hz,
start_sample,
iterations: 0,
snr_db,
})
}
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]],
) -> 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,
) 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(crate) fn decode_scan_for<P: ModulationParams>(
audio: &[f32],
sample_rate: u32,
nominal_start_sample: usize,
params: &super::search::SearchParams,
) -> Vec<Q65Result> {
decode_scan_inner::<P>(audio, sample_rate, nominal_start_sample, params, None)
}
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,
) -> Vec<Q65Result> {
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<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,
) 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
}
#[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 }
}
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>,
) -> Option<Q65Result> {
use crate::engine::{DecodeContext, MessageCodec};
use crate::msg::Q65Message;
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, &DecodeContext::default()) else {
continue;
};
let mut codeword = [0_i32; 63];
codec.encode(&info_syms, &mut codeword);
let snr_db = snr_db_wide::<P>(&energies, sample_rate, &codeword);
return Some(Q65Result {
message: text,
freq_hz: freq_shift,
start_sample: shifted_start,
iterations,
snr_db,
});
}
}
}
None
}
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>,
) -> Option<Q65Result> {
decode_at_grid_for::<P>(
audio,
sample_rate,
start_sample,
freq_hz,
GridDepth::Fast,
ap_hint,
)
}
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)
}
fn decode_averaged_ap_list_for<P: ModulationParams>(
audio_slots: &[&[f32]],
sample_rate: u32,
start_sample: usize,
base_freq_hz: f32,
candidates: &[[i32; 63]],
) -> Option<Q65Result> {
use crate::engine::{DecodeContext, MessageCodec};
use crate::msg::Q65Message;
if candidates.is_empty() {
return None;
}
let energies =
averaged_data_energies::<P>(audio_slots, 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())?;
let snr_db = snr_db_narrow::<P>(&energies, &candidates[idx]);
Some(Q65Result {
message: text,
freq_hz: base_freq_hz,
start_sample,
iterations: 0,
snr_db,
})
}
fn decode_fading_with_energies<P: ModulationParams>(
energies: &[f32],
sample_rate: u32,
start_sample: usize,
base_freq_hz: f32,
b90_ts: f32,
model: FadingModel,
) -> Option<Q65Result> {
use crate::engine::{DecodeContext, MessageCodec};
use crate::msg::Q65Message;
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, &DecodeContext::default())?;
let mut codeword = [0_i32; 63];
codec.encode(&info_syms, &mut codeword);
let snr_db = snr_db_wide::<P>(energies, sample_rate, &codeword);
Some(Q65Result {
message: text,
freq_hz: base_freq_hz,
start_sample,
iterations,
snr_db,
})
}
fn decode_averaged_plain_for<P: ModulationParams>(
audio_slots: &[&[f32]],
sample_rate: u32,
start_sample: usize,
base_freq_hz: f32,
) -> Option<Q65Result> {
use crate::engine::{DecodeContext, MessageCodec};
use crate::msg::Q65Message;
let energies =
averaged_data_energies::<P>(audio_slots, 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 = codec.decode(&intrinsics, &mut info_syms, 50).ok()?;
let bits77 = unpack_symbols_to_bits77(&info_syms);
let text = Q65Message.unpack(&bits77, &DecodeContext::default())?;
let mut codeword = [0_i32; 63];
codec.encode(&info_syms, &mut codeword);
let snr_db = snr_db_narrow::<P>(&energies, &codeword);
Some(Q65Result {
message: text,
freq_hz: base_freq_hz,
start_sample,
iterations,
snr_db,
})
}
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]]>,
) -> Vec<Q65Result> {
use super::search::{Spectrogram, coarse_search_on_spec_for};
let mut output: Vec<Q65Result> = Vec::new();
if audio_slots.is_empty() {
return output;
}
let mut ema_spec = Spectrogram::build_for::<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 = Spectrogram::build_for::<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 {
if let Some(codewords) = ap_codewords
&& let Some(d) = decode_averaged_ap_list_for::<P>(
history,
sample_rate,
cand.start_sample,
cand.freq_hz,
codewords,
)
{
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,
sample_rate,
cand.start_sample,
cand.freq_hz,
b90,
model,
) {
slot_decode = Some(d);
break 'candidate_loop;
}
}
}
}
if let Some(d) = decode_averaged_plain_for::<P>(
history,
sample_rate,
cand.start_sample,
cand.freq_hz,
) {
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 {
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:#?}"
);
}
}