use rustfft::{num_complex::Complex, FftPlanner};
use std::sync::Arc;
use crate::resample::WORKING_SAMPLE_RATE_HZ;
pub(crate) const LEADER_HZ: f64 = 1900.0;
pub(crate) const BREAK_HZ_OFFSET: f64 = -700.0; pub(crate) const BIT_ZERO_OFFSET: f64 = -600.0; pub(crate) const BIT_ONE_OFFSET: f64 = -800.0; pub(crate) const TONE_TOLERANCE_HZ: f64 = 25.0;
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
pub(crate) const HOP_SAMPLES: usize = (0.010 * WORKING_SAMPLE_RATE_HZ as f64) as usize;
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
pub(crate) const WINDOW_SAMPLES: usize = (0.020 * WORKING_SAMPLE_RATE_HZ as f64) as usize;
pub(crate) const FFT_LEN: usize = 512;
pub(crate) const HISTORY_LEN: usize = 45;
const SEARCH_LO_HZ: f64 = 500.0;
const SEARCH_HI_HZ: f64 = 3300.0;
pub(crate) struct VisDetector {
fft: Arc<dyn rustfft::Fft<f32>>,
hann: Vec<f32>,
fft_buf: Vec<Complex<f32>>,
scratch: Vec<Complex<f32>>,
audio_buffer: Vec<f32>,
audio_origin_sample: u64,
history: [f64; HISTORY_LEN],
history_ptr: usize,
history_filled: usize,
hops_completed: u64,
detected: Option<DetectedVis>,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct DetectedVis {
pub code: u8,
pub hedr_shift_hz: f64,
pub end_sample: u64,
}
impl VisDetector {
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: build_hann_window(WINDOW_SAMPLES),
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)],
audio_buffer: Vec::with_capacity(WINDOW_SAMPLES * 4),
audio_origin_sample: 0,
history: [0.0; HISTORY_LEN],
history_ptr: 0,
history_filled: 0,
hops_completed: 0,
detected: None,
}
}
pub fn process(&mut self, samples: &[f32], total_samples_consumed: u64) {
if self.detected.is_some() {
return;
}
if self.audio_buffer.is_empty() {
#[allow(clippy::cast_possible_truncation)]
let chunk_len = samples.len() as u64;
self.audio_origin_sample = total_samples_consumed.saturating_sub(chunk_len);
}
self.audio_buffer.extend_from_slice(samples);
loop {
let buf_window_start = self.next_window_start_in_buffer();
let buf_window_end = buf_window_start + WINDOW_SAMPLES;
if buf_window_end > self.audio_buffer.len() {
break;
}
self.process_hop(buf_window_start);
self.hops_completed = self.hops_completed.saturating_add(1);
if self.history_filled >= HISTORY_LEN {
if let Some((code, hedr_shift_hz, i_match)) =
match_vis_pattern(&self.rotated_history())
{
let stop_end_abs =
(self.hops_completed.saturating_add(i_match as u64)) * HOP_SAMPLES as u64;
let drain_to_buf =
usize::try_from(stop_end_abs.saturating_sub(self.audio_origin_sample))
.unwrap_or(usize::MAX)
.min(self.audio_buffer.len());
self.detected = Some(DetectedVis {
code,
hedr_shift_hz,
end_sample: stop_end_abs,
});
self.audio_buffer.drain(..drain_to_buf);
#[allow(clippy::cast_possible_truncation)]
{
self.audio_origin_sample += drain_to_buf as u64;
}
return;
}
}
let drain_to = buf_window_start + HOP_SAMPLES;
self.audio_buffer.drain(..drain_to);
#[allow(clippy::cast_possible_truncation)]
{
self.audio_origin_sample += drain_to as u64;
}
}
}
pub fn take_detected(&mut self) -> Option<DetectedVis> {
self.detected.take()
}
pub fn take_residual_buffer(&mut self) -> Vec<f32> {
std::mem::take(&mut self.audio_buffer)
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
fn next_window_start_in_buffer(&self) -> usize {
let next_hop_abs = self.hops_completed * HOP_SAMPLES as u64;
next_hop_abs.saturating_sub(self.audio_origin_sample) as usize
}
fn process_hop(&mut self, buf_window_start: usize) {
let window = &self.audio_buffer[buf_window_start..buf_window_start + WINDOW_SAMPLES];
for (i, slot) in self.fft_buf.iter_mut().enumerate() {
*slot = if i < WINDOW_SAMPLES {
Complex {
re: window[i] * self.hann[i],
im: 0.0,
}
} else {
Complex { re: 0.0, im: 0.0 }
};
}
self.fft
.process_with_scratch(&mut self.fft_buf, &mut self.scratch[..]);
let peak_hz = estimate_peak_freq(&self.fft_buf);
let prev_idx = (self.history_ptr + HISTORY_LEN - 1) % HISTORY_LEN;
self.history[self.history_ptr] = if peak_hz.is_finite() {
peak_hz
} else {
self.history[prev_idx]
};
self.history_ptr = (self.history_ptr + 1) % HISTORY_LEN;
if self.history_filled < HISTORY_LEN {
self.history_filled += 1;
}
}
fn rotated_history(&self) -> [f64; HISTORY_LEN] {
let mut out = [0.0_f64; HISTORY_LEN];
for (i, slot) in out.iter_mut().enumerate() {
*slot = self.history[(self.history_ptr + i) % HISTORY_LEN];
}
out
}
}
#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
fn build_hann_window(n: usize) -> Vec<f32> {
let m = (n.saturating_sub(1).max(1)) as f64;
(0..n)
.map(|i| {
let v = 0.5 * (1.0 - (2.0 * std::f64::consts::PI * (i as f64) / m).cos());
v as f32
})
.collect()
}
#[allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss
)]
fn estimate_peak_freq(spectrum: &[Complex<f32>]) -> f64 {
let fft_len = spectrum.len();
#[allow(clippy::cast_possible_truncation)]
let bin_for = |hz: f64| -> usize { crate::get_bin(hz, fft_len, WORKING_SAMPLE_RATE_HZ) };
let lo = bin_for(SEARCH_LO_HZ);
let hi = bin_for(SEARCH_HI_HZ);
if lo == 0 || hi >= fft_len.saturating_sub(1) || lo >= hi {
return f64::NAN;
}
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(spectrum[lo]);
for (k, &c) in spectrum.iter().enumerate().take(hi).skip(lo + 1) {
let p = power(c);
if p > max_p {
max_p = p;
max_bin = k;
}
}
if max_bin <= lo || max_bin >= hi {
return f64::NAN;
}
let p_prev = power(spectrum[max_bin - 1]);
let p_curr = max_p;
let p_next = power(spectrum[max_bin + 1]);
if p_prev <= 0.0 || p_curr <= 0.0 || p_next <= 0.0 {
return f64::NAN;
}
let num = (p_next / p_prev).ln();
let denom = 2.0 * (p_curr * p_curr / (p_next * p_prev)).ln();
let bin = if denom.abs() > 1e-12 {
max_bin as f64 + num / denom
} else {
max_bin as f64
};
bin / fft_len as f64 * f64::from(WORKING_SAMPLE_RATE_HZ)
}
fn match_vis_pattern(tones: &[f64; HISTORY_LEN]) -> Option<(u8, f64, usize)> {
let tol = TONE_TOLERANCE_HZ;
for i in 0..3 {
for j in 0..3 {
let leader = tones[j];
if !within(tones[3 + i], leader, tol)
|| !within(tones[6 + i], leader, tol)
|| !within(tones[9 + i], leader, tol)
|| !within(tones[12 + i], leader, tol)
{
continue;
}
let break_target = leader + BREAK_HZ_OFFSET;
if !within(tones[15 + i], break_target, tol)
|| !within(tones[42 + i], break_target, tol)
{
continue;
}
let zero_target = leader + BIT_ZERO_OFFSET;
let one_target = leader + BIT_ONE_OFFSET;
let mut code = 0u8;
let mut parity = 0u8;
let mut bit_ok = true;
for k in 0..8 {
let t = tones[18 + i + 3 * k];
let bit = if within(t, zero_target, tol) {
0u8
} else if within(t, one_target, tol) {
1u8
} else {
bit_ok = false;
break;
};
if k < 7 {
code |= bit << k;
parity ^= bit;
} else {
let expected = if code == 0x06 { bit ^ 1 } else { bit };
if parity != expected {
bit_ok = false;
}
}
}
if bit_ok {
return Some((code, leader - LEADER_HZ, i));
}
}
}
None
}
#[inline]
fn within(value: f64, target: f64, tol: f64) -> bool {
(value - target).abs() < tol
}
#[allow(clippy::cast_precision_loss)]
pub(crate) fn goertzel_power(samples: &[f32], target_hz: f64) -> f64 {
let n = samples.len() as f64;
if n == 0.0 {
return 0.0;
}
let k = (0.5 + n * target_hz / f64::from(WORKING_SAMPLE_RATE_HZ)).floor();
let coeff = 2.0 * (2.0 * std::f64::consts::PI * k / n).cos();
let mut s_prev = 0.0_f64;
let mut s_prev2 = 0.0_f64;
for &sample in samples {
let s = f64::from(sample) + coeff * s_prev - s_prev2;
s_prev2 = s_prev;
s_prev = s;
}
s_prev2.mul_add(s_prev2, s_prev.mul_add(s_prev, -coeff * s_prev * s_prev2))
}
#[cfg(any(test, feature = "test-support"))]
#[doc(hidden)]
#[allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_possible_wrap,
clippy::float_cmp,
clippy::expect_used,
clippy::wildcard_imports,
clippy::must_use_candidate,
dead_code
)]
pub mod tests {
use super::*;
use std::f64::consts::PI;
pub fn synth_tone(freq_hz: f64, secs: f64) -> Vec<f32> {
let n = (secs * f64::from(WORKING_SAMPLE_RATE_HZ)).round() as usize;
synth_tone_n(freq_hz, n)
}
pub fn synth_tone_n(freq_hz: f64, n: usize) -> Vec<f32> {
(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()
}
pub fn synth_vis_with_offset(code: u8, pre_silence_secs: f64, freq_offset_hz: f64) -> Vec<f32> {
assert!(code < 0x80, "VIS codes are 7 bits");
let sr = f64::from(WORKING_SAMPLE_RATE_HZ);
let mut out: Vec<f32> = vec![0.0; (pre_silence_secs * sr).round() as usize];
let mut phase = 0.0_f64;
let mut emit = |freq: f64, secs: f64, out: &mut Vec<f32>| {
let dphi = 2.0 * PI * freq / sr;
for _ in 0..(secs * sr).round() as usize {
out.push(phase.sin() as f32);
phase += dphi;
if phase > 2.0 * PI {
phase -= 2.0 * PI;
}
}
};
let leader = LEADER_HZ + freq_offset_hz;
let break_f = leader + BREAK_HZ_OFFSET;
let bit_freq = |bit: u8| -> f64 {
leader
+ if bit == 1 {
BIT_ONE_OFFSET
} else {
BIT_ZERO_OFFSET
}
};
emit(leader, 0.300, &mut out);
emit(break_f, 0.030, &mut out);
let mut parity = 0u8;
for b in 0..7 {
let bit = (code >> b) & 1;
parity ^= bit;
emit(bit_freq(bit), 0.030, &mut out);
}
let parity_bit = if code == 0x06 { parity ^ 1 } else { parity };
emit(bit_freq(parity_bit), 0.030, &mut out);
emit(break_f, 0.030, &mut out);
out
}
pub fn synth_vis(code: u8, pre_silence_secs: f64) -> Vec<f32> {
synth_vis_with_offset(code, pre_silence_secs, 0.0)
}
fn run(audio: &[f32]) -> Option<DetectedVis> {
let mut det = VisDetector::new();
det.process(audio, audio.len() as u64);
det.take_detected()
}
fn vis_padded(code: u8, pre_silence_secs: f64, freq_offset_hz: f64) -> Vec<f32> {
let mut audio = synth_vis_with_offset(code, pre_silence_secs, freq_offset_hz);
audio.extend(std::iter::repeat_n(0.0_f32, 256));
audio
}
#[test]
fn empty_input_returns_zero_power() {
assert_eq!(goertzel_power(&[], 1900.0), 0.0);
}
#[test]
fn goertzel_handcomputed_quarter_cycle() {
let samples = [1.0_f32, 0.0, -1.0, 0.0];
let target = f64::from(WORKING_SAMPLE_RATE_HZ) / 4.0;
let p = goertzel_power(&samples, target);
assert!((p - 4.0).abs() < 1e-9, "expected 4.0, got {p}");
}
#[test]
fn hann_window_endpoints_are_zero() {
let h = build_hann_window(WINDOW_SAMPLES);
assert!(h[0].abs() < 1e-6);
assert!(h[h.len() - 1].abs() < 1e-6);
let mid = h.len() / 2;
assert!((h[mid] - 1.0).abs() < 1e-2, "middle ≈ 1, got {}", h[mid]);
}
#[test]
fn detects_clean_pd120_and_pd180() {
for &code in &[0x5F_u8, 0x60] {
let d = run(&vis_padded(code, 0.0, 0.0)).expect("clean detect");
assert_eq!(d.code, code);
assert!(d.hedr_shift_hz.abs() < 10.0);
}
}
#[test]
fn detects_pd120_with_50hz_offset() {
let d = run(&vis_padded(0x5F, 0.050, 50.0)).expect("offset PD120");
assert_eq!(d.code, 0x5F);
assert!(
(d.hedr_shift_hz - 50.0).abs() < 10.0,
"got {}",
d.hedr_shift_hz
);
}
#[test]
fn detects_pd180_with_minus_70hz_offset() {
let d = run(&vis_padded(0x60, 0.080, -70.0)).expect("offset PD180");
assert_eq!(d.code, 0x60);
assert!(
(d.hedr_shift_hz + 70.0).abs() < 10.0,
"got {}",
d.hedr_shift_hz
);
}
#[test]
fn detects_with_pre_silence_aligned_or_misaligned() {
for pre_samples in [(7 * HOP_SAMPLES) as f64, 37.0] {
let pre_secs = pre_samples / f64::from(WORKING_SAMPLE_RATE_HZ);
let d = run(&vis_padded(0x5F, pre_secs, 0.0)).expect("detect after silence");
assert_eq!(d.code, 0x5F);
}
}
#[test]
fn rejects_isolated_noise() {
let mut x: u64 = 0xdead_beef_cafe_babe;
let n = WORKING_SAMPLE_RATE_HZ as usize;
let audio: Vec<f32> = (0..n)
.map(|_| {
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
(((x as i64) as f64) / (i64::MAX as f64)) as f32 * 0.3
})
.collect();
assert!(run(&audio).is_none());
}
#[test]
fn rejects_constant_off_band_tone() {
let n = WORKING_SAMPLE_RATE_HZ as usize;
let audio = synth_tone_n(1750.0, n);
assert!(run(&audio).is_none());
}
#[test]
fn r12bw_uses_inverted_parity() {
let audio = vis_padded(0x06, 0.0, 0.0);
let detected = run(&audio);
assert!(
detected.is_some(),
"R12BW (0x06) with inverted parity should decode at the \
VIS-classifier level (V1's modespec::lookup will then drop \
it because R12BW isn't in the V1 table)."
);
let detected = detected.expect("R12BW decode");
assert_eq!(detected.code, 0x06);
}
#[test]
fn r12bw_rejects_standard_parity() {
let sr = f64::from(WORKING_SAMPLE_RATE_HZ);
let mut out: Vec<f32> = vec![0.0; 0];
let mut phase = 0.0_f64;
let mut emit = |freq: f64, secs: f64, out: &mut Vec<f32>| {
let dphi = 2.0 * PI * freq / sr;
for _ in 0..(secs * sr).round() as usize {
out.push(phase.sin() as f32);
phase += dphi;
if phase > 2.0 * PI {
phase -= 2.0 * PI;
}
}
};
let break_f = LEADER_HZ + BREAK_HZ_OFFSET;
let bit_freq = |bit: u8| {
LEADER_HZ
+ if bit == 1 {
BIT_ONE_OFFSET
} else {
BIT_ZERO_OFFSET
}
};
emit(LEADER_HZ, 0.300, &mut out);
emit(break_f, 0.030, &mut out);
let mut parity = 0u8;
for b in 0..7 {
let bit = (0x06_u8 >> b) & 1;
parity ^= bit;
emit(bit_freq(bit), 0.030, &mut out);
}
emit(bit_freq(parity), 0.030, &mut out);
emit(break_f, 0.030, &mut out);
out.extend(std::iter::repeat_n(0.0_f32, 256));
assert!(
run(&out).is_none(),
"0x06 with standard (non-inverted) parity must NOT decode — \
slowrx vis.c:116 inverts parity for R12BW."
);
}
#[test]
fn parity_failure_is_rejected() {
let mut audio = synth_vis(0x5F, 0.0);
let sr = f64::from(WORKING_SAMPLE_RATE_HZ);
let bit5_start = ((0.300 + 0.030 + 5.0 * 0.030) * sr) as usize;
let bit5_end = bit5_start + (0.030 * sr) as usize;
for s in &mut audio[bit5_start..bit5_end] {
*s = 0.0;
}
audio.extend(std::iter::repeat_n(0.0_f32, 256));
assert!(run(&audio).is_none());
}
#[cfg(test)]
mod prop {
use super::*;
use proptest::prelude::*;
proptest! {
#![proptest_config(ProptestConfig::with_cases(64))]
#[test]
fn detector_does_not_panic_on_arbitrary_audio(
len in 0usize..32_000,
seed in 0u64..u64::MAX,
) {
let mut x = seed.max(1);
let mut audio = Vec::with_capacity(len);
for _ in 0..len {
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
let v = ((x as i64) as f64) / (i64::MAX as f64);
audio.push(v as f32);
}
let _ = run(&audio);
}
#[test]
fn every_valid_vis_code_decodes_correctly(code in 0u8..0x80) {
let audio = vis_padded(code, 0.0, 0.0);
let d = run(&audio).expect("clean VIS always decodes");
prop_assert_eq!(d.code, code);
}
}
}
}