use rustfft::{num_complex::Complex, FftPlanner};
use std::sync::Arc;
use crate::modespec::ModeSpec;
use crate::resample::WORKING_SAMPLE_RATE_HZ;
pub(crate) const SYNC_PROBE_STRIDE: usize = 4;
pub(crate) const SYNC_FFT_WINDOW_SAMPLES: usize = 16;
pub(crate) const SYNC_FFT_LEN: usize = 256;
const MIN_SLANT_DEG: f64 = 30.0;
const MAX_SLANT_DEG: f64 = 150.0;
const SLANT_STEP_DEG: f64 = 0.5;
const SLANT_OK_LO_DEG: f64 = 89.0;
const SLANT_OK_HI_DEG: f64 = 91.0;
const MAX_SLANT_RETRIES: usize = 3;
const X_ACC_BINS: usize = 700;
const SYNC_IMG_Y_BINS: usize = 630;
const LINES_D_BINS: usize = 600;
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
const X_ACC_SLIP_THRESHOLD: i32 = (X_ACC_BINS / 2) as i32;
const SYNC_EDGE_KERNEL: [i32; 8] = [1, 1, 1, 1, -1, -1, -1, -1];
const SYNC_EDGE_KERNEL_LEN: usize = SYNC_EDGE_KERNEL.len();
fn deg2rad(deg: f64) -> f64 {
deg * std::f64::consts::PI / 180.0
}
pub(crate) struct SyncTracker {
fft: Arc<dyn rustfft::Fft<f32>>,
hann: Vec<f32>,
fft_buf: Vec<Complex<f32>>,
scratch: Vec<Complex<f32>>,
sync_target_bin: usize,
video_lo_bin: usize,
video_hi_bin: usize,
}
impl SyncTracker {
#[allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss
)]
pub fn new(hedr_shift_hz: f64) -> Self {
let mut planner = FftPlanner::<f32>::new();
let fft = planner.plan_fft_forward(SYNC_FFT_LEN);
let scratch_len = fft.get_inplace_scratch_len();
let bin_for =
|hz: f64| -> usize { crate::dsp::get_bin(hz, SYNC_FFT_LEN, WORKING_SAMPLE_RATE_HZ) };
Self {
fft,
hann: build_sync_hann(),
fft_buf: vec![Complex { re: 0.0, im: 0.0 }; SYNC_FFT_LEN],
scratch: vec![Complex { re: 0.0, im: 0.0 }; scratch_len.max(SYNC_FFT_LEN)],
sync_target_bin: bin_for(1200.0 + hedr_shift_hz),
video_lo_bin: bin_for(1500.0 + hedr_shift_hz),
video_hi_bin: bin_for(2300.0 + hedr_shift_hz),
}
}
#[allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_possible_wrap
)]
pub fn has_sync_at(&mut self, audio: &[f32], center_sample: usize) -> bool {
let half = (SYNC_FFT_WINDOW_SAMPLES as i64) / 2;
self.fft_buf.fill(Complex { re: 0.0, im: 0.0 });
for i in 0..SYNC_FFT_WINDOW_SAMPLES {
let idx = (center_sample as i64) - half + (i as i64);
let s = if idx >= 0 && (idx as usize) < audio.len() {
audio[idx as usize]
} else {
0.0
};
self.fft_buf[i].re = s * self.hann[i];
}
self.fft
.process_with_scratch(&mut self.fft_buf, &mut self.scratch[..]);
let mut p_raw = 0.0_f64;
let lo = self.video_lo_bin.max(1);
let hi = self.video_hi_bin.min(SYNC_FFT_LEN / 2 - 1);
if hi >= lo {
for k in lo..=hi {
p_raw += crate::dsp::power(self.fft_buf[k]);
}
p_raw /= (hi - lo).max(1) as f64;
}
let mut p_sync = 0.0_f64;
let bin = self.sync_target_bin.clamp(1, SYNC_FFT_LEN / 2 - 1);
for offset in -1_i32..=1 {
let k = (bin as i32 + offset) as usize;
let weight = 1.0 - 0.5 * f64::from(offset.abs());
p_sync += crate::dsp::power(self.fft_buf[k]) * weight;
}
p_sync /= 2.0;
p_sync > 2.0 * p_raw
}
}
fn build_sync_hann() -> Vec<f32> {
crate::dsp::build_hann(SYNC_FFT_WINDOW_SAMPLES)
}
#[derive(Clone, Copy, Debug)]
pub(crate) struct SyncResult {
pub adjusted_rate_hz: f64,
pub skip_samples: i64,
#[allow(dead_code)]
pub slant_deg: Option<f64>,
}
#[allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_possible_wrap
)]
pub(crate) fn find_sync(has_sync: &[bool], initial_rate_hz: f64, spec: ModeSpec) -> SyncResult {
let line_width: usize = ((spec.line_seconds / spec.sync_seconds) * 4.0) as usize;
let num_lines = spec.image_lines as usize;
let mut rate = initial_rate_hz;
let mut slant_deg_detected: Option<f64> = None;
for retry in 0..=MAX_SLANT_RETRIES {
let Some((slant, adjusted)) = hough_detect_slant(has_sync, rate, spec, line_width) else {
break;
};
slant_deg_detected = Some(slant);
if (slant - 90.0).abs() > SLANT_STEP_DEG {
rate = adjusted;
}
if (slant > SLANT_OK_LO_DEG && slant < SLANT_OK_HI_DEG) || retry == MAX_SLANT_RETRIES {
break;
}
}
let xmax = find_falling_edge(has_sync, rate, spec, num_lines);
let s_secs = skip_seconds_for(xmax, spec);
let skip_samples = (s_secs * rate).round() as i64;
SyncResult {
adjusted_rate_hz: rate,
skip_samples,
slant_deg: slant_deg_detected,
}
}
#[allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_possible_wrap
)]
fn hough_detect_slant(
has_sync: &[bool],
rate_hz: f64,
spec: ModeSpec,
line_width: usize,
) -> Option<(f64 /* slant_deg */, f64 /* adjusted_rate */)> {
let n_slant_bins = ((MAX_SLANT_DEG - MIN_SLANT_DEG) / SLANT_STEP_DEG).round() as usize;
let num_lines = spec.image_lines as usize;
let sync_img_idx = |x: usize, y: usize| x * SYNC_IMG_Y_BINS + y;
let lines_idx = |d: usize, q: usize| d * n_slant_bins + q;
let probe_index = |t: f64| -> usize {
let raw = t * rate_hz / (SYNC_PROBE_STRIDE as f64);
if raw < 0.0 {
0
} else {
raw as usize
}
};
let mut sync_img = vec![false; X_ACC_BINS * SYNC_IMG_Y_BINS];
for y in 0..num_lines.min(SYNC_IMG_Y_BINS) {
for x in 0..line_width.min(X_ACC_BINS) {
let t = ((y as f64) + (x as f64) / (line_width as f64)) * spec.line_seconds;
let idx = probe_index(t);
if idx < has_sync.len() {
sync_img[sync_img_idx(x, y)] = has_sync[idx];
}
}
}
let mut lines = vec![0u16; LINES_D_BINS * n_slant_bins];
let mut q_most = 0_usize;
let mut max_count = 0_u16;
for cy in 0..num_lines.min(SYNC_IMG_Y_BINS) {
for cx in 0..line_width.min(X_ACC_BINS) {
if !sync_img[sync_img_idx(cx, cy)] {
continue;
}
for q in 0..n_slant_bins {
let theta = deg2rad(MIN_SLANT_DEG + (q as f64) * SLANT_STEP_DEG);
let d_signed = (line_width as f64)
+ (-(cx as f64) * theta.sin() + (cy as f64) * theta.cos()).round();
if d_signed > 0.0 && d_signed < (line_width as f64) {
let d = d_signed as usize;
if d < LINES_D_BINS {
let cell = &mut lines[lines_idx(d, q)];
*cell = cell.saturating_add(1);
if *cell > max_count {
max_count = *cell;
q_most = q;
}
}
}
}
}
}
if max_count == 0 {
return None;
}
let slant_angle = MIN_SLANT_DEG + (q_most as f64) * SLANT_STEP_DEG;
let adjusted_rate =
rate_hz + (deg2rad(90.0 - slant_angle).tan() / (line_width as f64)) * rate_hz;
Some((slant_angle, adjusted_rate))
}
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
fn falling_edge_from_x_acc(x_acc: &[u32]) -> i32 {
debug_assert_eq!(x_acc.len(), X_ACC_BINS, "x_acc must be X_ACC_BINS long");
let mut xmax: i32 = 0;
let mut max_convd: i32 = 0;
for (x, window) in x_acc
.windows(SYNC_EDGE_KERNEL_LEN)
.take(X_ACC_BINS - SYNC_EDGE_KERNEL_LEN)
.enumerate()
{
let convd: i32 = window
.iter()
.zip(SYNC_EDGE_KERNEL.iter())
.map(|(&v, &k)| (v as i32) * k)
.sum();
if convd > max_convd {
max_convd = convd;
xmax = (x as i32) + (SYNC_EDGE_KERNEL_LEN as i32) / 2;
}
}
if xmax > X_ACC_SLIP_THRESHOLD {
xmax -= X_ACC_SLIP_THRESHOLD;
}
xmax
}
#[allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss
)]
fn find_falling_edge(has_sync: &[bool], rate_hz: f64, spec: ModeSpec, num_lines: usize) -> i32 {
let probe_index = |t: f64| -> usize {
let raw = t * rate_hz / (SYNC_PROBE_STRIDE as f64);
if raw < 0.0 {
0
} else {
raw as usize
}
};
let mut x_acc = vec![0u32; X_ACC_BINS];
for y in 0..num_lines {
for (x, slot) in x_acc.iter_mut().enumerate() {
let t = (y as f64) * spec.line_seconds
+ ((x as f64) / (X_ACC_BINS as f64)) * spec.line_seconds;
let idx = probe_index(t);
if idx < has_sync.len() && has_sync[idx] {
*slot = slot.saturating_add(1);
}
}
}
falling_edge_from_x_acc(&x_acc)
}
#[allow(clippy::cast_precision_loss)]
fn skip_seconds_for(xmax: i32, spec: ModeSpec) -> f64 {
let raw = (f64::from(xmax) / (X_ACC_BINS as f64)) * spec.line_seconds - spec.sync_seconds;
raw + spec.skip_correction_seconds()
}
#[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::*;
use crate::modespec;
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()
}
#[test]
fn has_sync_at_detects_1200_hz_burst() {
let mut tracker = SyncTracker::new(0.0);
let audio = synth_tone(1200.0, 0.050);
assert!(tracker.has_sync_at(&audio, audio.len() / 2));
}
#[test]
fn has_sync_at_rejects_1900_hz_tone() {
let mut tracker = SyncTracker::new(0.0);
let audio = synth_tone(1900.0, 0.050);
assert!(!tracker.has_sync_at(&audio, audio.len() / 2));
}
#[test]
fn has_sync_at_rejects_silence() {
let mut tracker = SyncTracker::new(0.0);
assert!(!tracker.has_sync_at(&vec![0.0_f32; 1024], 512));
}
fn synth_has_sync(spec: ModeSpec, rate_hz: f64) -> Vec<bool> {
let total = (f64::from(spec.image_lines) * spec.line_seconds * rate_hz
/ (SYNC_PROBE_STRIDE as f64)) as usize
+ 16;
let mut track = vec![false; total];
for y in 0..spec.image_lines {
let i_start =
(f64::from(y) * spec.line_seconds * rate_hz / (SYNC_PROBE_STRIDE as f64)) as usize;
let i_end = ((f64::from(y) * spec.line_seconds + spec.sync_seconds) * rate_hz
/ (SYNC_PROBE_STRIDE as f64)) as usize;
for slot in track.iter_mut().take(i_end.min(total)).skip(i_start) {
*slot = true;
}
}
track
}
fn synth_has_sync_slanted(
spec: ModeSpec,
true_rate_hz: f64,
_capture_rate_hz: f64,
) -> Vec<bool> {
let total = (f64::from(spec.image_lines) * spec.line_seconds * true_rate_hz
/ (SYNC_PROBE_STRIDE as f64)) as usize
+ 16;
let mut track = vec![false; total];
for y in 0..spec.image_lines {
let i_start = (f64::from(y) * spec.line_seconds * true_rate_hz
/ (SYNC_PROBE_STRIDE as f64)) as usize;
let i_end =
i_start + (spec.sync_seconds * true_rate_hz / (SYNC_PROBE_STRIDE as f64)) as usize;
for slot in track.iter_mut().take(i_end.min(total)).skip(i_start) {
*slot = true;
}
}
track
}
#[test]
fn find_sync_locks_clean_track_to_90_degrees() {
let spec = modespec::for_mode(crate::modespec::SstvMode::Pd120);
let rate = f64::from(WORKING_SAMPLE_RATE_HZ);
let r = find_sync(&synth_has_sync(spec, rate), rate, spec);
let slant = r.slant_deg.expect("sync detected");
assert!((slant - 90.0).abs() < 1.0, "{slant:.2}°");
assert!((r.adjusted_rate_hz - rate).abs() / rate < 0.005);
assert!(r.skip_samples.abs() < (0.05 * rate) as i64);
}
#[test]
fn find_sync_empty_track_has_no_slant_detected() {
let spec = modespec::for_mode(crate::modespec::SstvMode::Pd120);
let rate = f64::from(WORKING_SAMPLE_RATE_HZ);
let r = find_sync(&vec![false; 16384], rate, spec);
assert!(
r.slant_deg.is_none(),
"empty track should yield slant_deg=None, got {:?}",
r.slant_deg
);
assert!(
r.skip_samples < 0,
"empty track skip should be negative (xmax=0)"
);
}
#[test]
fn find_sync_recovers_known_offset() {
let spec = modespec::for_mode(crate::modespec::SstvMode::Pd120);
let rate = f64::from(WORKING_SAMPLE_RATE_HZ);
let mut track = synth_has_sync(spec, rate);
let shift = ((0.010 * rate) / (SYNC_PROBE_STRIDE as f64)) as usize;
let mut shifted = vec![false; shift];
shifted.append(&mut track);
let r = find_sync(&shifted, rate, spec);
let expected = (0.010 * rate) as i64;
assert!(
(r.skip_samples - expected).abs() < (0.005 * rate) as i64,
"Skip off (expected ≈ {expected}, got {})",
r.skip_samples
);
}
#[test]
fn find_sync_handles_empty_track() {
let spec = modespec::for_mode(crate::modespec::SstvMode::Pd120);
let rate = f64::from(WORKING_SAMPLE_RATE_HZ);
let r = find_sync(&vec![false; 16384], rate, spec);
assert!(r.adjusted_rate_hz.is_finite());
assert!((r.adjusted_rate_hz - rate).abs() < 1.0);
assert!(
(r.adjusted_rate_hz - rate).abs() < f64::EPSILON,
"rate should be unchanged, got {}",
r.adjusted_rate_hz
);
}
#[test]
fn find_sync_corrects_0p5pct_slant_at_pd120() {
let spec = modespec::for_mode(crate::modespec::SstvMode::Pd120);
let true_rate = f64::from(WORKING_SAMPLE_RATE_HZ);
let capture_rate = true_rate * 1.005;
let track = synth_has_sync_slanted(spec, true_rate, capture_rate);
let r = find_sync(&track, capture_rate, spec);
let err_pct = (r.adjusted_rate_hz - true_rate).abs() / true_rate * 100.0;
let initial_err_pct = (capture_rate - true_rate).abs() / true_rate * 100.0;
assert!(
r.slant_deg.is_some(),
"expected sync to be detected (slant_deg should be Some)"
);
assert!(
r.adjusted_rate_hz < capture_rate,
"adjusted rate {:.2} should be less than capture_rate {capture_rate:.2} (slant correction moved it toward true_rate)",
r.adjusted_rate_hz
);
assert!(
err_pct < initial_err_pct / 2.0,
"rate err {err_pct:.3}% should be < half of initial {initial_err_pct:.3}%"
);
}
#[test]
fn find_sync_corrects_1pct_slant_via_retries() {
let spec = modespec::for_mode(crate::modespec::SstvMode::Pd120);
let true_rate = f64::from(WORKING_SAMPLE_RATE_HZ);
let capture_rate = true_rate * 1.01;
let track = synth_has_sync_slanted(spec, true_rate, capture_rate);
let r = find_sync(&track, capture_rate, spec);
let initial_err_pct = (capture_rate - true_rate).abs() / true_rate * 100.0;
let final_err_pct = (r.adjusted_rate_hz - true_rate).abs() / true_rate * 100.0;
assert!(
final_err_pct < initial_err_pct,
"retry should shrink rate error: initial {initial_err_pct:.3}% → final {final_err_pct:.3}%"
);
assert!(
final_err_pct < 0.2,
"rate err after retries should be ≤ 0.2%, got {final_err_pct:.3}%"
);
}
#[test]
fn find_sync_scottie_applies_skip_correction() {
let spec = modespec::for_mode(crate::modespec::SstvMode::Scottie1);
let rate = f64::from(WORKING_SAMPLE_RATE_HZ);
let track = synth_has_sync(spec, rate);
let r = find_sync(&track, rate, spec);
let chan_len = f64::from(spec.line_pixels) * spec.pixel_seconds;
let expected_secs = -chan_len / 2.0 + 2.0 * spec.porch_seconds;
let expected_skip = (expected_secs * rate).round() as i64;
let tolerance = (0.005 * rate) as i64;
assert!(
(r.skip_samples - expected_skip).abs() < tolerance,
"Scottie skip {} should be ≈ {expected_skip} (correction = {expected_secs:.4}s, tol = {tolerance})",
r.skip_samples
);
assert!(
r.skip_samples < 0,
"Scottie skip should be negative (mid-line hoist); got {}",
r.skip_samples
);
}
#[test]
fn falling_edge_from_x_acc_off_by_one_a6() {
let mut x_acc = vec![0u32; X_ACC_BINS];
for slot in x_acc.iter_mut().take(696).skip(691) {
*slot = 100;
}
let xmax = falling_edge_from_x_acc(&x_acc);
assert_eq!(
xmax, 345,
"post-A6-fix should pick n=691 (xmax=695, slip=345); pre-fix would give 346"
);
}
#[test]
fn falling_edge_from_x_acc_detects_mid_array_edge() {
let mut x_acc = vec![0u32; X_ACC_BINS];
for slot in x_acc.iter_mut().take(104).skip(100) {
*slot = 100;
}
let xmax = falling_edge_from_x_acc(&x_acc);
assert_eq!(xmax, 104, "mid-array edge detection unchanged by A6 fix");
}
}