use crate::error::Result;
use crate::image::SstvImage;
use crate::modespec::SstvMode;
use crate::resample::Resampler;
use crate::sync::{find_sync, SyncTracker, SYNC_PROBE_STRIDE};
#[derive(Clone, Debug)]
#[non_exhaustive]
pub enum SstvEvent {
VisDetected {
mode: SstvMode,
sample_offset: u64,
hedr_shift_hz: f64,
},
LineDecoded {
mode: SstvMode,
line_index: u32,
pixels: Vec<[u8; 3]>,
},
ImageComplete {
image: SstvImage,
partial: bool,
},
}
enum State {
AwaitingVis,
Decoding(Box<DecodingState>),
}
struct DecodingState {
mode: SstvMode,
spec: crate::modespec::ModeSpec,
image: SstvImage,
audio: Vec<f32>,
has_sync: Vec<bool>,
next_probe_sample: usize,
sync_tracker: SyncTracker,
hedr_shift_hz: f64,
target_audio_samples: usize,
}
const FINDSYNC_AUDIO_HEADROOM: f64 = 1.00;
pub struct SstvDecoder {
resampler: Resampler,
vis: crate::vis::VisDetector,
pd_demod: crate::mode_pd::PdDemod,
snr_est: crate::snr::SnrEstimator,
state: State,
samples_processed: u64,
working_samples_emitted: u64,
}
impl SstvDecoder {
pub fn new(input_sample_rate_hz: u32) -> Result<Self> {
Ok(Self {
resampler: Resampler::new(input_sample_rate_hz)?,
vis: crate::vis::VisDetector::new(),
pd_demod: crate::mode_pd::PdDemod::new(),
snr_est: crate::snr::SnrEstimator::new(),
state: State::AwaitingVis,
samples_processed: 0,
working_samples_emitted: 0,
})
}
#[allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss
)]
pub fn process(&mut self, audio: &[f32]) -> Vec<SstvEvent> {
let working = self.resampler.process(audio);
self.samples_processed = self.samples_processed.saturating_add(audio.len() as u64);
self.working_samples_emitted = self
.working_samples_emitted
.saturating_add(working.len() as u64);
let mut out = Vec::new();
let mut remaining: &[f32] = working.as_slice();
loop {
match &mut self.state {
State::AwaitingVis => {
self.vis.process(remaining, self.working_samples_emitted);
remaining = &[];
if let Some(detected) = self.vis.take_detected() {
if let Some(spec) = crate::modespec::lookup(detected.code) {
out.push(SstvEvent::VisDetected {
mode: spec.mode,
sample_offset: detected.end_sample,
hedr_shift_hz: detected.hedr_shift_hz,
});
let image =
SstvImage::new(spec.mode, spec.line_pixels, spec.image_lines);
let residual = self.vis.take_residual_buffer();
let work_rate = f64::from(crate::resample::WORKING_SAMPLE_RATE_HZ);
let nominal_samples =
(f64::from(spec.image_lines / 2) * spec.line_seconds * work_rate)
as usize;
let target =
((nominal_samples as f64) * FINDSYNC_AUDIO_HEADROOM) as usize;
self.state = State::Decoding(Box::new(DecodingState {
mode: spec.mode,
spec,
image,
audio: residual,
has_sync: Vec::new(),
next_probe_sample: 0,
sync_tracker: SyncTracker::new(detected.hedr_shift_hz),
hedr_shift_hz: detected.hedr_shift_hz,
target_audio_samples: target,
}));
continue; }
let _ = self.vis.take_residual_buffer();
}
break;
}
State::Decoding(d) => {
d.audio.extend_from_slice(remaining);
while d.next_probe_sample + SYNC_PROBE_STRIDE * 2 <= d.audio.len() {
let center = d.next_probe_sample + SYNC_PROBE_STRIDE / 2;
let has = d.sync_tracker.has_sync_at(&d.audio, center);
d.has_sync.push(has);
d.next_probe_sample += SYNC_PROBE_STRIDE;
}
if d.audio.len() < d.target_audio_samples {
break;
}
Self::run_findsync_and_decode(
d,
&mut self.pd_demod,
&mut self.snr_est,
&mut out,
);
let trailing = std::mem::take(&mut d.audio);
self.state = State::AwaitingVis;
self.vis = crate::vis::VisDetector::new();
self.vis.process(&trailing, self.working_samples_emitted);
break;
}
}
}
out
}
#[allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_possible_wrap
)]
fn run_findsync_and_decode(
d: &mut DecodingState,
pd_demod: &mut crate::mode_pd::PdDemod,
snr_est: &mut crate::snr::SnrEstimator,
out: &mut Vec<SstvEvent>,
) {
let work_rate = f64::from(crate::resample::WORKING_SAMPLE_RATE_HZ);
let result = find_sync(&d.has_sync, work_rate, d.spec);
let rate = result.adjusted_rate_hz;
let skip = result.skip_samples;
let line_pixels = d.spec.line_pixels as usize;
let pair_count = d.spec.image_lines / 2;
for pair in 0..pair_count {
let pair_seconds = f64::from(pair) * d.spec.line_seconds;
crate::mode_pd::decode_pd_line_pair(
d.spec,
pair,
&d.audio,
skip,
pair_seconds,
rate,
&mut d.image,
pd_demod,
snr_est,
d.hedr_shift_hz,
);
let row0 = pair * 2;
let row1 = row0 + 1;
for r in [row0, row1] {
let start = (r as usize) * line_pixels;
let end = start + line_pixels;
out.push(SstvEvent::LineDecoded {
mode: d.mode,
line_index: r,
pixels: d.image.pixels[start..end].to_vec(),
});
}
}
let final_image = std::mem::replace(
&mut d.image,
SstvImage::new(d.mode, d.spec.line_pixels, d.spec.image_lines),
);
out.push(SstvEvent::ImageComplete {
image: final_image,
partial: false,
});
}
pub fn reset(&mut self) {
self.state = State::AwaitingVis;
self.samples_processed = 0;
self.working_samples_emitted = 0;
self.vis = crate::vis::VisDetector::new();
self.resampler.reset_state();
self.pd_demod = crate::mode_pd::PdDemod::new();
self.snr_est = crate::snr::SnrEstimator::new();
}
#[must_use]
pub fn samples_processed(&self) -> u64 {
self.samples_processed
}
}
#[must_use]
#[allow(clippy::cast_precision_loss, dead_code)]
pub(crate) fn estimate_freq(window: &[f32]) -> f64 {
const STEP_HZ: f64 = 25.0;
const FIRST_HZ: f64 = 1450.0;
const N_BINS: usize = 37;
let mut powers = [0.0_f64; N_BINS];
for (i, p) in powers.iter_mut().enumerate() {
let f = FIRST_HZ + (i as f64) * STEP_HZ;
*p = crate::vis::goertzel_power(window, f);
}
let (mut max_i, mut max_p) = (0_usize, powers[0]);
for (i, &p) in powers.iter().enumerate().skip(1) {
if p > max_p {
max_p = p;
max_i = i;
}
}
let center_hz = FIRST_HZ + (max_i as f64) * STEP_HZ;
if max_i > 0 && max_i < N_BINS - 1 && max_p > 0.0 {
let a = powers[max_i - 1];
let b = max_p;
let c = powers[max_i + 1];
let denom = a - 2.0 * b + c;
if denom.abs() > 1e-12 {
let delta = 0.5 * (a - c) / denom;
return center_hz + delta * STEP_HZ;
}
}
center_hz
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss
)]
mod tests {
use super::*;
use crate::error::Error;
use crate::resample::{MAX_INPUT_SAMPLE_RATE_HZ, WORKING_SAMPLE_RATE_HZ};
#[test]
fn rejects_invalid_sample_rates() {
assert!(matches!(
SstvDecoder::new(0),
Err(Error::InvalidSampleRate { got: 0 })
));
assert!(matches!(
SstvDecoder::new(MAX_INPUT_SAMPLE_RATE_HZ + 1),
Err(Error::InvalidSampleRate { .. })
));
}
#[test]
fn accepts_common_rates() {
assert!(SstvDecoder::new(11_025).is_ok());
assert!(SstvDecoder::new(44_100).is_ok());
assert!(SstvDecoder::new(48_000).is_ok());
}
#[test]
fn process_advances_sample_counter() {
let mut d = SstvDecoder::new(11_025).expect("decoder");
assert_eq!(d.samples_processed(), 0);
let _ = d.process(&[0.0_f32; 1024]);
assert_eq!(d.samples_processed(), 1024);
let _ = d.process(&[0.0_f32; 256]);
assert_eq!(d.samples_processed(), 1280);
}
#[test]
fn process_returns_no_events_for_silence() {
let mut d = SstvDecoder::new(11_025).expect("decoder");
let events = d.process(&[0.5_f32; 512]);
assert!(events.is_empty());
}
#[test]
fn process_emits_vis_detected_for_pd120_burst() {
use crate::vis::tests::synth_vis;
let mut d = SstvDecoder::new(WORKING_SAMPLE_RATE_HZ).expect("decoder");
let mut burst = synth_vis(0x5F, 0.0);
burst.extend(std::iter::repeat_n(0.0_f32, 512));
let events = d.process(&burst);
let hedr = events
.iter()
.find_map(|e| match e {
SstvEvent::VisDetected {
mode: SstvMode::Pd120,
hedr_shift_hz,
..
} => Some(*hedr_shift_hz),
_ => None,
})
.expect("expected VisDetected for PD120");
assert!(
hedr.abs() < 10.0,
"synthetic burst should report ~0 Hz shift, got {hedr}"
);
}
#[test]
fn process_emits_vis_detected_for_pd180_burst() {
use crate::vis::tests::synth_vis;
let mut d = SstvDecoder::new(WORKING_SAMPLE_RATE_HZ).expect("decoder");
let mut burst = synth_vis(0x60, 0.0);
burst.extend(std::iter::repeat_n(0.0_f32, 512));
let events = d.process(&burst);
let hedr = events
.iter()
.find_map(|e| match e {
SstvEvent::VisDetected {
mode: SstvMode::Pd180,
hedr_shift_hz,
..
} => Some(*hedr_shift_hz),
_ => None,
})
.expect("expected VisDetected for PD180");
assert!(hedr.abs() < 10.0);
}
#[test]
fn reset_clears_sample_counter() {
let mut d = SstvDecoder::new(11_025).expect("decoder");
let _ = d.process(&[0.0_f32; 1024]);
d.reset();
assert_eq!(d.samples_processed(), 0);
}
fn synth_tone_at_working(freq_hz: f64, secs: f64) -> Vec<f32> {
let sr = f64::from(WORKING_SAMPLE_RATE_HZ);
let n = (secs * sr).round() as usize;
(0..n)
.map(|i| (2.0 * std::f64::consts::PI * freq_hz * (i as f64) / sr).sin() as f32)
.collect()
}
#[test]
fn estimate_freq_recovers_known_tone() {
for &f in &[1500.0_f64, 1700.0, 1900.0, 2100.0, 2300.0] {
let window = synth_tone_at_working(f, 0.040);
let est = estimate_freq(&window);
assert!((est - f).abs() < 30.0, "freq={f} estimate={est}");
}
}
#[test]
fn estimate_freq_no_interp_at_left_boundary() {
let window = synth_tone_at_working(1450.0, 0.040);
let est = estimate_freq(&window);
assert!((est - 1450.0).abs() < 30.0, "expected ≈1450, got {est}");
}
#[test]
fn reset_during_decoding_emits_partial_via_subsequent_process() {
let mut d = SstvDecoder::new(crate::resample::WORKING_SAMPLE_RATE_HZ).unwrap();
let mut burst = crate::vis::tests::synth_vis(0x5F, 0.0);
burst.extend(std::iter::repeat_n(0.0_f32, 512));
let events = d.process(&burst);
assert!(
events
.iter()
.any(|e| matches!(e, SstvEvent::VisDetected { .. })),
"expected VIS detection before reset, got {events:?}"
);
d.reset();
let events = d.process(&[0.0_f32; 100]);
assert!(
events.is_empty(),
"reset should clear in-flight; got {events:?}"
);
}
}