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;
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::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 power = |c: Complex<f32>| -> f64 {
let r = f64::from(c.re);
let i = f64::from(c.im);
r * r + i * i
};
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 += 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 += power(self.fft_buf[k]) * weight;
}
p_sync /= 2.0;
p_sync > 2.0 * p_raw
}
}
#[allow(clippy::cast_precision_loss)]
fn build_sync_hann() -> Vec<f32> {
(0..SYNC_FFT_WINDOW_SAMPLES)
.map(|i| {
let m = (SYNC_FFT_WINDOW_SAMPLES - 1) as f32;
0.5 * (1.0 - (2.0 * std::f32::consts::PI * (i as f32) / m).cos())
})
.collect()
}
#[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,
clippy::too_many_lines
)]
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 n_slant_bins = ((MAX_SLANT_DEG - MIN_SLANT_DEG) / SLANT_STEP_DEG).round() as usize;
let mut rate = initial_rate_hz;
let mut slant_deg_detected: Option<f64> = None;
let num_lines = spec.image_lines as usize;
let probe_index = |t: f64, rate_hz: 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];
let mut lines = vec![0u16; LINES_D_BINS * n_slant_bins];
for retry in 0..=MAX_SLANT_RETRIES {
sync_img.fill(false);
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, rate);
if idx < has_sync.len() {
sync_img[x * SYNC_IMG_Y_BINS + y] = has_sync[idx];
}
}
}
lines.fill(0);
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[cx * SYNC_IMG_Y_BINS + 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[d * n_slant_bins + q];
*cell = cell.saturating_add(1);
if *cell > max_count {
max_count = *cell;
q_most = q;
}
}
}
}
}
}
if max_count == 0 {
break;
}
let slant_angle = MIN_SLANT_DEG + (q_most as f64) * SLANT_STEP_DEG;
slant_deg_detected = Some(slant_angle);
if (slant_angle - 90.0).abs() > SLANT_STEP_DEG {
rate += (deg2rad(90.0 - slant_angle).tan() / (line_width as f64)) * rate;
}
if (slant_angle > SLANT_OK_LO_DEG && slant_angle < SLANT_OK_HI_DEG)
|| retry == MAX_SLANT_RETRIES
{
break;
}
}
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, rate);
if idx < has_sync.len() && has_sync[idx] {
*slot = slot.saturating_add(1);
}
}
}
let kernel: [i32; 8] = [1, 1, 1, 1, -1, -1, -1, -1];
let mut xmax: i32 = 0;
let mut max_convd: i32 = 0;
for (x, window) in x_acc.windows(8).enumerate() {
let convd: i32 = window
.iter()
.zip(kernel.iter())
.map(|(&v, &k)| (v as i32) * k)
.sum();
if convd > max_convd {
max_convd = convd;
xmax = (x as i32) + 4;
}
}
if xmax > 350 {
xmax -= 350;
}
let s_secs = (f64::from(xmax) / (X_ACC_BINS as f64)) * spec.line_seconds - spec.sync_seconds;
let skip_samples = (s_secs * rate).round() as i64;
SyncResult {
adjusted_rate_hz: rate,
skip_samples,
slant_deg: slant_deg_detected,
}
}
#[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
}
#[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
);
}
}