use rustfft::{num_complex::Complex, FftPlanner};
#[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 = crate::snr::FFT_LEN;
pub(crate) struct PdDemod {
fft: std::sync::Arc<dyn rustfft::Fft<f32>>,
hann_bank: crate::snr::HannBank,
fft_buf: Vec<Complex<f32>>,
scratch: Vec<Complex<f32>>,
}
impl PdDemod {
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: crate::snr::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(crate::snr::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::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 power = |c: Complex<f32>| -> f64 {
let r = f64::from(c.re);
let i = f64::from(c.im);
r * r + i * i
};
let mut max_bin = lo;
let mut max_p = power(self.fft_buf[lo]);
for (k, &c) in self.fft_buf.iter().enumerate().take(hi + 1).skip(lo + 1) {
let p = 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 = power(self.fft_buf[max_bin - 1]);
let p_curr = max_p;
let p_next = 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)
}
}
const PIXEL_FFT_STRIDE: i64 = 1;
pub(crate) const SNR_REESTIMATE_STRIDE: i64 = 64;
#[allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_possible_wrap,
clippy::too_many_arguments
)]
pub(crate) fn decode_pd_line_pair(
spec: crate::modespec::ModeSpec,
pair_index: u32,
audio: &[f32],
skip_samples: i64,
pair_seconds: f64,
rate_hz: f64,
image: &mut crate::image::SstvImage,
demod: &mut PdDemod,
snr_est: &mut crate::snr::SnrEstimator,
hedr_shift_hz: f64,
) {
let sync_secs = spec.sync_seconds;
let porch_secs = spec.porch_seconds;
let pixel_secs = spec.pixel_seconds;
let septr_secs = spec.septr_seconds;
let width = spec.line_pixels;
let chan_len = f64::from(width) * pixel_secs;
let chan_starts_sec = [
sync_secs + porch_secs, sync_secs + porch_secs + chan_len + septr_secs, sync_secs + porch_secs + 2.0 * chan_len + 2.0 * septr_secs, sync_secs + porch_secs + 3.0 * chan_len + 3.0 * septr_secs, ];
let row0 = pair_index * 2;
let row1 = row0 + 1;
let width_us = width as usize;
let chan_bounds_abs: [(i64, i64); 4] = std::array::from_fn(|i| {
let start_sec = chan_starts_sec[i];
let end_sec = start_sec + f64::from(width) * pixel_secs;
let start_abs = skip_samples + ((pair_seconds + start_sec) * rate_hz).round() as i64;
let end_abs = skip_samples + ((pair_seconds + end_sec) * rate_hz).round() as i64;
(start_abs, end_abs)
});
let mut y_odd = vec![0_u8; width_us];
let mut cr = vec![0_u8; width_us];
let mut cb = vec![0_u8; width_us];
let mut y_even = vec![0_u8; width_us];
let buffers: [&mut [u8]; 4] = [&mut y_odd, &mut cr, &mut cb, &mut y_even];
for (chan_idx, buf) in buffers.into_iter().enumerate() {
decode_one_channel_into(
buf,
chan_starts_sec[chan_idx],
chan_bounds_abs[chan_idx],
spec,
audio,
skip_samples,
pair_seconds,
rate_hz,
demod,
snr_est,
hedr_shift_hz,
);
}
for x in 0..width_us {
let rgb_odd = ycbcr_to_rgb(y_odd[x], cr[x], cb[x]);
let rgb_even = ycbcr_to_rgb(y_even[x], cr[x], cb[x]);
image.put_pixel(x as u32, row0, rgb_odd);
image.put_pixel(x as u32, row1, rgb_even);
}
}
#[allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_possible_wrap,
clippy::too_many_arguments
)]
fn decode_one_channel_into(
out: &mut [u8],
chan_start_sec: f64,
chan_bounds_abs: (i64, i64),
spec: crate::modespec::ModeSpec,
audio: &[f32],
skip_samples: i64,
pair_seconds: f64,
rate_hz: f64,
demod: &mut PdDemod,
snr_est: &mut crate::snr::SnrEstimator,
hedr_shift_hz: f64,
) {
let pixel_secs = spec.pixel_seconds;
let width = spec.line_pixels as usize;
let mut pixel_times: Vec<i64> = Vec::with_capacity(width);
for x in 0..width {
let secs_in_pair = chan_start_sec + pixel_secs * (x as f64 + 0.5);
let abs = skip_samples + ((pair_seconds + secs_in_pair) * 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 + hedr_shift_hz;
let _ = chan_bounds_abs;
let read_audio = |abs_idx: i64| -> f32 {
if abs_idx >= 0 && (abs_idx as usize) < audio.len() {
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 = snr_est.estimate(audio, s, hedr_shift_hz);
}
if mod_round(s, PIXEL_FFT_STRIDE) == 0 {
let win_idx = crate::snr::window_idx_for_snr(snr_db);
let center_in_scratch = s - sweep_start;
current_freq =
demod.pixel_freq(&scratch_audio, center_in_scratch, hedr_shift_hz, win_idx);
}
let lum = freq_to_luminance(current_freq, 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::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
mod tests {
use super::*;
#[test]
fn chan_starts_sec_septr_zero_is_numerically_equivalent_to_old_formula() {
for spec in [
crate::modespec::for_mode(crate::modespec::SstvMode::Pd120),
crate::modespec::for_mode(crate::modespec::SstvMode::Pd180),
] {
let sync = spec.sync_seconds;
let porch = spec.porch_seconds;
let px = spec.pixel_seconds;
let septr = spec.septr_seconds;
let w = f64::from(spec.line_pixels);
let chan_len = w * px;
let new_starts = [
sync + porch,
sync + porch + chan_len + septr,
sync + porch + 2.0 * chan_len + 2.0 * septr,
sync + porch + 3.0 * chan_len + 3.0 * septr,
];
let old_starts = [
sync + porch,
sync + porch + w * px,
sync + porch + 2.0 * w * px,
sync + porch + 3.0 * w * px,
];
for (n, (n_val, o_val)) in new_starts.iter().zip(old_starts.iter()).enumerate() {
assert!(
(n_val - o_val).abs() < 1e-12,
"mode {:?} chan {} new={n_val} old={o_val}",
spec.mode,
n
);
}
}
}
#[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 = PdDemod::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 = PdDemod::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 = PdDemod::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 = PdDemod::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 = PdDemod::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 = PdDemod::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 = PdDemod::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}"
);
}
}