use rustfft::{num_complex::Complex, FftPlanner};
pub(crate) const HANN_LENS: [usize; 7] = [12, 16, 24, 32, 64, 128, 256];
pub(crate) struct HannBank {
windows: [Vec<f32>; 7],
}
impl HannBank {
pub fn new() -> Self {
Self {
windows: [
crate::dsp::build_hann(HANN_LENS[0]),
crate::dsp::build_hann(HANN_LENS[1]),
crate::dsp::build_hann(HANN_LENS[2]),
crate::dsp::build_hann(HANN_LENS[3]),
crate::dsp::build_hann(HANN_LENS[4]),
crate::dsp::build_hann(HANN_LENS[5]),
crate::dsp::build_hann(HANN_LENS[6]),
],
}
}
#[must_use]
pub fn get(&self, idx: usize) -> &[f32] {
&self.windows[idx]
}
}
impl Default for HannBank {
fn default() -> Self {
Self::new()
}
}
#[must_use]
pub(crate) fn window_idx_for_snr(snr_db: f64) -> usize {
if snr_db >= 20.0 {
0
} else if snr_db >= 10.0 {
1
} else if snr_db >= 9.0 {
2
} else if snr_db >= 3.0 {
3
} else if snr_db >= -5.0 {
4
} else if snr_db >= -10.0 {
5
} else {
6
}
}
#[must_use]
pub(crate) fn window_idx_for_snr_with_hysteresis(snr_db: f64, prev_idx: usize) -> usize {
const HYSTERESIS_DB_HALF: f64 = 0.5;
let baseline = window_idx_for_snr(snr_db);
if baseline == prev_idx {
return prev_idx;
}
let target_idx = if baseline > prev_idx {
prev_idx + 1
} else {
prev_idx - 1
};
let shifted_snr = if target_idx < prev_idx {
snr_db - HYSTERESIS_DB_HALF
} else {
snr_db + HYSTERESIS_DB_HALF
};
let shifted_idx = window_idx_for_snr(shifted_snr);
let robust = if target_idx < prev_idx {
shifted_idx <= target_idx
} else {
shifted_idx >= target_idx
};
if robust {
target_idx
} else {
prev_idx
}
}
#[must_use]
pub(crate) fn freq_to_luminance(freq_hz: f64, hedr_shift_hz: f64) -> u8 {
let v = (freq_hz - 1500.0 - hedr_shift_hz) / 3.137_254_9;
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let lum = v.clamp(0.0, 255.0).round() as u8;
lum
}
#[must_use]
#[doc(hidden)]
pub fn ycbcr_to_rgb(y: u8, cr: u8, cb: u8) -> [u8; 3] {
let yi = f64::from(y);
let cri = f64::from(cr);
let cbi = f64::from(cb);
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let r = ((100.0 * yi + 140.0 * cri - 17_850.0) / 100.0)
.clamp(0.0, 255.0)
.round() as u8;
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let g = ((100.0 * yi - 71.0 * cri - 33.0 * cbi + 13_260.0) / 100.0)
.clamp(0.0, 255.0)
.round() as u8;
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let b = ((100.0 * yi + 178.0 * cbi - 22_695.0) / 100.0)
.clamp(0.0, 255.0)
.round() as u8;
[r, g, b]
}
pub(crate) const FFT_LEN: usize = 1024;
pub(crate) struct ChannelDemod {
fft: std::sync::Arc<dyn rustfft::Fft<f32>>,
hann_bank: HannBank,
fft_buf: Vec<Complex<f32>>,
scratch: Vec<Complex<f32>>,
}
impl ChannelDemod {
pub fn new() -> Self {
let mut planner = FftPlanner::<f32>::new();
let fft = planner.plan_fft_forward(FFT_LEN);
let scratch_len = fft.get_inplace_scratch_len();
Self {
fft,
hann_bank: HannBank::new(),
fft_buf: vec![Complex { re: 0.0, im: 0.0 }; FFT_LEN],
scratch: vec![Complex { re: 0.0, im: 0.0 }; scratch_len.max(FFT_LEN)],
}
}
#[allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_possible_wrap
)]
pub fn pixel_freq(
&mut self,
audio: &[f32],
center_sample: i64,
hedr_shift_hz: f64,
win_idx: usize,
) -> f64 {
let win_idx = win_idx.min(HANN_LENS.len() - 1);
let hann = self.hann_bank.get(win_idx);
let win_len = hann.len();
for c in &mut self.fft_buf {
*c = Complex { re: 0.0, im: 0.0 };
}
let half = (win_len as i64) / 2;
for (i, (&w, dst)) in hann.iter().zip(self.fft_buf.iter_mut()).enumerate() {
let idx = center_sample - half + i as i64;
let s = if idx >= 0 && (idx as usize) < audio.len() {
audio[idx as usize]
} else {
0.0
};
*dst = Complex { re: s * w, im: 0.0 };
}
self.fft
.process_with_scratch(&mut self.fft_buf, &mut self.scratch[..]);
let bin_for = |hz: f64| -> usize {
crate::dsp::get_bin(hz, FFT_LEN, crate::resample::WORKING_SAMPLE_RATE_HZ)
};
let lo = bin_for(1500.0 + hedr_shift_hz).saturating_sub(1).max(1);
let hi = bin_for(2300.0 + hedr_shift_hz)
.saturating_add(1)
.min(FFT_LEN / 2 - 1);
let mut max_bin = lo;
let mut max_p = crate::dsp::power(self.fft_buf[lo]);
for (k, &c) in self.fft_buf.iter().enumerate().take(hi + 1).skip(lo + 1) {
let p = crate::dsp::power(c);
if p > max_p {
max_p = p;
max_bin = k;
}
}
let mid_bin = bin_for(1900.0 + hedr_shift_hz);
if max_bin <= lo || max_bin >= hi {
let clipped_hz = if max_bin > mid_bin {
2300.0 + hedr_shift_hz
} else {
1500.0 + hedr_shift_hz
};
return clipped_hz;
}
let p_prev = crate::dsp::power(self.fft_buf[max_bin - 1]);
let p_curr = max_p;
let p_next = crate::dsp::power(self.fft_buf[max_bin + 1]);
let interp_ok = p_prev > 0.0 && p_curr > 0.0 && p_next > 0.0;
let freq_bin = if interp_ok {
let num = (p_next / p_prev).ln();
let denom = 2.0 * (p_curr * p_curr / (p_next * p_prev)).ln();
if denom.abs() > 1e-12 {
(max_bin as f64) + num / denom
} else {
max_bin as f64
}
} else {
max_bin as f64
};
freq_bin * f64::from(crate::resample::WORKING_SAMPLE_RATE_HZ) / (FFT_LEN as f64)
}
}
impl Default for ChannelDemod {
fn default() -> Self {
Self::new()
}
}
pub(crate) const SNR_REESTIMATE_STRIDE: i64 = 64;
const PIXEL_FFT_STRIDE: i64 = 1;
pub(crate) struct ChannelDecodeCtx<'a> {
pub audio: &'a [f32],
pub skip_samples: i64,
pub rate_hz: f64,
pub hedr_shift_hz: f64,
pub spec: crate::modespec::ModeSpec,
}
pub(crate) struct DemodState<'a> {
pub demod: &'a mut ChannelDemod,
pub snr_est: &'a mut crate::snr::SnrEstimator,
}
#[allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_possible_wrap
)]
pub(crate) fn decode_one_channel_into(
out: &mut [u8],
chan_start_sec: f64,
radio_frame_offset_seconds: f64,
ctx: &ChannelDecodeCtx<'_>,
state: &mut DemodState<'_>,
) {
let pixel_secs = ctx.spec.pixel_seconds;
let width = ctx.spec.line_pixels as usize;
let mut pixel_times: Vec<i64> = Vec::with_capacity(width);
for x in 0..width {
let secs_in_frame = chan_start_sec + pixel_secs * (x as f64 + 0.5);
let abs = ctx.skip_samples
+ ((radio_frame_offset_seconds + secs_in_frame) * ctx.rate_hz).round() as i64;
pixel_times.push(abs);
}
let first_time = pixel_times[0];
let last_time = pixel_times[width - 1];
let half_fft = (FFT_LEN as i64) / 2;
let sweep_start = first_time - half_fft;
let sweep_end = last_time + half_fft + 1;
let sweep_len = (sweep_end - sweep_start).max(0) as usize;
let mut stored_lum = vec![0_u8; sweep_len];
let mut snr_db = 0.0_f64;
let mut current_freq = 1500.0_f64 + ctx.hedr_shift_hz;
let mut prev_win_idx = HANN_LENS.len() - 1;
let read_audio = |abs_idx: i64| -> f32 {
if abs_idx >= 0 && (abs_idx as usize) < ctx.audio.len() {
ctx.audio[abs_idx as usize]
} else {
0.0
}
};
let scratch_audio: Vec<f32> = (sweep_start..sweep_end).map(read_audio).collect();
let mod_round = |s: i64, stride: i64| -> i64 { s.rem_euclid(stride) };
for s in sweep_start..sweep_end {
if mod_round(s, SNR_REESTIMATE_STRIDE) == 0 {
snr_db = state.snr_est.estimate(ctx.audio, s, ctx.hedr_shift_hz);
}
if mod_round(s, PIXEL_FFT_STRIDE) == 0 {
let mut win_idx = window_idx_for_snr_with_hysteresis(snr_db, prev_win_idx);
prev_win_idx = win_idx;
if ctx.spec.mode == crate::modespec::SstvMode::ScottieDx
&& win_idx < HANN_LENS.len() - 1
{
win_idx += 1;
}
let center_in_scratch = s - sweep_start;
current_freq = state.demod.pixel_freq(
&scratch_audio,
center_in_scratch,
ctx.hedr_shift_hz,
win_idx,
);
}
let lum = freq_to_luminance(current_freq, ctx.hedr_shift_hz);
let idx = (s - sweep_start) as usize;
if idx < stored_lum.len() {
stored_lum[idx] = lum;
}
}
for x in 0..width {
let pixel_time = pixel_times[x];
let rel = pixel_time - sweep_start;
let lum = if rel >= 0 && (rel as usize) < stored_lum.len() {
stored_lum[rel as usize]
} else {
0
};
out[x] = lum;
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_possible_wrap
)]
mod tests {
use super::*;
#[test]
fn hann_lens_match_slowrx_at_workingrate() {
assert_eq!(HANN_LENS, [12, 16, 24, 32, 64, 128, 256]);
}
#[test]
fn hann_bank_lengths_correct() {
let bank = HannBank::new();
for (i, &expected_len) in HANN_LENS.iter().enumerate() {
assert_eq!(bank.get(i).len(), expected_len, "idx={i}");
}
}
#[test]
fn hann_window_endpoints_are_zero() {
let bank = HannBank::new();
for idx in 0..7 {
let w = bank.get(idx);
assert!(w[0].abs() < 1e-6, "idx={idx} w[0]={}", w[0]);
assert!(
w[w.len() - 1].abs() < 1e-6,
"idx={idx} w[end]={}",
w[w.len() - 1]
);
}
}
#[test]
fn window_idx_thresholds_match_slowrx() {
assert_eq!(window_idx_for_snr(30.0), 0);
assert_eq!(window_idx_for_snr(20.0), 0);
assert_eq!(window_idx_for_snr(19.999), 1);
assert_eq!(window_idx_for_snr(10.0), 1);
assert_eq!(window_idx_for_snr(9.999), 2);
assert_eq!(window_idx_for_snr(9.0), 2);
assert_eq!(window_idx_for_snr(8.999), 3);
assert_eq!(window_idx_for_snr(3.0), 3);
assert_eq!(window_idx_for_snr(2.999), 4);
assert_eq!(window_idx_for_snr(-5.0), 4);
assert_eq!(window_idx_for_snr(-5.001), 5);
assert_eq!(window_idx_for_snr(-10.0), 5);
assert_eq!(window_idx_for_snr(-10.001), 6);
assert_eq!(window_idx_for_snr(-100.0), 6);
}
#[test]
fn hysteresis_in_band_stays_put() {
assert_eq!(window_idx_for_snr_with_hysteresis(9.3, 3), 3);
}
#[test]
fn hysteresis_robust_change_propagates() {
assert_eq!(window_idx_for_snr_with_hysteresis(9.5, 3), 2);
}
#[test]
fn hysteresis_symmetric_in_band() {
assert_eq!(window_idx_for_snr_with_hysteresis(8.5, 2), 2);
}
#[test]
fn hysteresis_symmetric_robust() {
assert_eq!(window_idx_for_snr_with_hysteresis(8.0, 2), 3);
}
#[test]
fn hysteresis_no_change_when_in_equilibrium() {
assert_eq!(window_idx_for_snr_with_hysteresis(15.0, 1), 1);
}
#[test]
fn hysteresis_at_extreme_thresholds() {
assert_eq!(window_idx_for_snr_with_hysteresis(20.5, 1), 0);
assert_eq!(window_idx_for_snr_with_hysteresis(-10.5, 5), 5);
assert_eq!(window_idx_for_snr_with_hysteresis(-11.0, 5), 6);
}
#[test]
fn hysteresis_ratchets_from_distant_prev_low_snr() {
assert_eq!(window_idx_for_snr_with_hysteresis(9.2, 4), 3);
}
#[test]
fn hysteresis_ratchets_from_distant_prev_high_snr() {
assert_eq!(window_idx_for_snr_with_hysteresis(20.2, 4), 3);
}
#[test]
fn hysteresis_converges_high_snr_from_cold_start() {
let mut idx = 6;
let snr = 25.0;
for expected in [5, 4, 3, 2, 1, 0] {
idx = window_idx_for_snr_with_hysteresis(snr, idx);
assert_eq!(
idx, expected,
"ratchet should land at {expected}, got {idx}"
);
}
assert_eq!(window_idx_for_snr_with_hysteresis(snr, idx), 0);
}
#[test]
fn hysteresis_converges_degrading_snr() {
let mut idx = 0;
let snr = -50.0;
for expected in [1, 2, 3, 4, 5, 6] {
idx = window_idx_for_snr_with_hysteresis(snr, idx);
assert_eq!(
idx, expected,
"ratchet should land at {expected}, got {idx}"
);
}
assert_eq!(window_idx_for_snr_with_hysteresis(snr, idx), 6);
}
#[test]
fn hann_bank_default_constructs() {
let _ = HannBank::default();
}
#[test]
fn freq_1500_is_black() {
assert_eq!(freq_to_luminance(1500.0, 0.0), 0);
}
#[test]
fn freq_2300_is_white() {
assert_eq!(freq_to_luminance(2300.0, 0.0), 255);
}
#[test]
fn freq_below_band_clamps_to_zero() {
assert_eq!(freq_to_luminance(1000.0, 0.0), 0);
assert_eq!(freq_to_luminance(0.0, 0.0), 0);
}
#[test]
fn freq_above_band_clamps_to_max() {
assert_eq!(freq_to_luminance(3000.0, 0.0), 255);
}
#[test]
fn freq_midband_is_midgrey() {
let v = freq_to_luminance(1900.0, 0.0);
assert!(
(i32::from(v) - 128).abs() <= 1,
"midband should be ~128 after rounding, got {v}"
);
}
#[test]
fn freq_to_luminance_rounds_to_nearest_not_truncates() {
let freq = 1500.0 + 127.7 * 3.137_254_9; assert_eq!(
freq_to_luminance(freq, 0.0),
128,
"127.7 should round to 128, not truncate to 127"
);
}
#[test]
fn freq_to_luminance_with_hedr_shift_scales_band() {
assert_eq!(freq_to_luminance(1550.0, 50.0), 0);
assert_eq!(freq_to_luminance(2350.0, 50.0), 255);
assert_eq!(freq_to_luminance(1500.0, 50.0), 0);
let a = freq_to_luminance(1950.0, 50.0);
let b = freq_to_luminance(1900.0, 0.0);
assert!(
i32::from(a).abs_diff(i32::from(b)) <= 1,
"shifted midband {a} vs unshifted {b}"
);
}
#[test]
fn ycbcr_neutral_grey_is_grey() {
let rgb = ycbcr_to_rgb(128, 128, 128);
for ch in &rgb {
assert!((i32::from(*ch) - 128).abs() <= 2, "got {rgb:?}");
}
}
#[test]
fn ycbcr_rounds_to_nearest_matching_slowrx_clip() {
let [r, g, b] = ycbcr_to_rgb(128, 128, 128);
assert_eq!(
r, 129,
"R should be 129 (round(128.70)), not 128 (truncate)"
);
assert_eq!(g, 127, "G should be 127 (round(127.48))");
assert_eq!(
b, 129,
"B should be 129 (round(128.89)), not 128 (truncate)"
);
}
#[test]
fn ycbcr_pure_red() {
let rgb = ycbcr_to_rgb(76, 255, 85);
assert!(rgb[0] > 200, "red channel should dominate, got {rgb:?}");
assert!(rgb[2] < 100);
}
use crate::resample::WORKING_SAMPLE_RATE_HZ;
use std::f64::consts::PI;
fn synth_tone(freq_hz: f64, secs: f64) -> Vec<f32> {
let n = (secs * f64::from(WORKING_SAMPLE_RATE_HZ)).round() as usize;
(0..n)
.map(|i| {
let t = (i as f64) / f64::from(WORKING_SAMPLE_RATE_HZ);
(2.0 * PI * freq_hz * t).sin() as f32
})
.collect()
}
const DEFAULT_WIN_IDX: usize = 4;
#[test]
fn pdfft_recovers_known_tone_within_5hz() {
let mut d = ChannelDemod::new();
let audio = synth_tone(1900.0, 0.100);
let center = (audio.len() / 2) as i64;
let est = d.pixel_freq(&audio, center, 0.0, DEFAULT_WIN_IDX);
assert!((est - 1900.0).abs() < 5.0, "expected ≈1900, got {est}");
}
#[test]
fn pdfft_recovers_band_edges() {
let mut d = ChannelDemod::new();
for &f in &[1500.0_f64, 1700.0, 2100.0, 2300.0] {
let audio = synth_tone(f, 0.100);
let center = (audio.len() / 2) as i64;
let est = d.pixel_freq(&audio, center, 0.0, DEFAULT_WIN_IDX);
assert!(
(est - f).abs() < 8.0,
"f={f} estimate={est} (band edge precision)"
);
}
}
#[test]
fn pdfft_returns_finite_for_silence() {
let mut d = ChannelDemod::new();
let audio = vec![0.0_f32; 1024];
let est = d.pixel_freq(&audio, 512, 0.0, DEFAULT_WIN_IDX);
assert!(est.is_finite(), "got {est}");
assert!((1450.0..=2350.0).contains(&est), "out of band: {est}");
}
#[test]
fn pixel_freq_with_hedr_shift() {
let mut d = ChannelDemod::new();
let audio = synth_tone(1950.0, 0.100);
let center = (audio.len() / 2) as i64;
let est_shifted = d.pixel_freq(&audio, center, 50.0, DEFAULT_WIN_IDX);
let est_unshifted_baseline = {
let baseline = synth_tone(1900.0, 0.100);
d.pixel_freq(&baseline, (baseline.len() / 2) as i64, 0.0, DEFAULT_WIN_IDX)
};
assert!((est_shifted - 1950.0).abs() < 5.0, "got {est_shifted}");
let lum_shifted = freq_to_luminance(est_shifted, 50.0);
let lum_unshifted = freq_to_luminance(est_unshifted_baseline, 0.0);
assert!(
i32::from(lum_shifted).abs_diff(i32::from(lum_unshifted)) <= 2,
"lum_shifted={lum_shifted} lum_unshifted={lum_unshifted}"
);
}
#[test]
fn pixel_freq_clamps_out_of_range_win_idx() {
let mut d = ChannelDemod::new();
let audio = synth_tone(1900.0, 0.100);
let center = (audio.len() / 2) as i64;
let est = d.pixel_freq(&audio, center, 0.0, 99);
assert!((est - 1900.0).abs() < 10.0, "clamp recover got {est}");
}
#[test]
fn pixel_freq_short_window_still_recovers_tone() {
let mut d = ChannelDemod::new();
let audio = synth_tone(1900.0, 0.100);
let center = (audio.len() / 2) as i64;
let est = d.pixel_freq(&audio, center, 0.0, 0);
assert!(
(1500.0..=2300.0).contains(&est),
"short-window estimate {est} out of video band"
);
}
#[test]
fn pixel_freq_clips_below_band_to_1500hz() {
let mut d = ChannelDemod::new();
let audio = synth_tone(1480.0, 0.100);
let center = (audio.len() / 2) as i64;
let est = d.pixel_freq(&audio, center, 0.0, DEFAULT_WIN_IDX);
assert!(
est <= 1500.0 + 50.0,
"below-band tone should clip to ≈1500 Hz, got {est}"
);
assert!(
est >= 1400.0,
"clip floor should be near 1500 Hz, got {est}"
);
}
}