use crate::error::ConfigError;
use crate::types::{BaudRate, SampleRate, TonePair, phase_increment, sine_at};
pub const MAX_WINDOW: usize = 240;
const RECIP_MAX_LEN: usize = 181;
fn orthogonal_window(sr: u32, bd: u32, shift: u32, samples_per_bit: usize) -> Option<usize> {
if shift == 0 || 4 * u64::from(shift) >= 3 * u64::from(bd) {
return None;
}
let period = ((u64::from(sr) + u64::from(shift) / 2) / u64::from(shift)) as usize;
if period == 0 {
return None;
}
let window = period * samples_per_bit.div_ceil(period);
(window <= MAX_WINDOW).then_some(window)
}
pub trait Discriminator {
fn push_i16(&mut self, sample: i16) -> i32;
fn push_f32(&mut self, sample: f32) -> i32;
}
#[derive(Debug, Clone)]
struct ToneCorrelator {
window: [(i64, i64); MAX_WINDOW],
len: usize,
pos: usize,
phase: u32,
phase_inc: u32,
i_sum: i64,
q_sum: i64,
recip: u64,
}
impl ToneCorrelator {
const QUARTER_TURN: u32 = 1 << 30;
const RECIP_SHIFT: u32 = 39;
fn new(tone_hz: u32, sample_rate: u32, len: usize) -> Self {
let len = len.clamp(1, MAX_WINDOW);
Self {
window: [(0, 0); MAX_WINDOW],
len,
pos: 0,
phase: 0,
phase_inc: phase_increment(tone_hz, sample_rate),
i_sum: 0,
q_sum: 0,
recip: if len <= RECIP_MAX_LEN {
(1u64 << Self::RECIP_SHIFT).div_ceil(len as u64)
} else {
0
},
}
}
#[inline]
fn scale(&self, sum: i64) -> i64 {
let x = sum.unsigned_abs() >> 8;
let q = if self.recip != 0 {
(x.wrapping_mul(self.recip) >> Self::RECIP_SHIFT) as i64
} else {
(x / self.len as u64) as i64
};
if sum < 0 { -q } else { q }
}
fn push(&mut self, sample: i32) -> i64 {
let sin_ref = sine_at(self.phase) as i64;
let cos_ref = sine_at(self.phase.wrapping_add(Self::QUARTER_TURN)) as i64;
self.phase = self.phase.wrapping_add(self.phase_inc);
let contrib = ((sample as i64) * sin_ref, (sample as i64) * cos_ref);
if let Some(slot) = self.window.get_mut(self.pos) {
let (old_i, old_q) = *slot;
self.i_sum = self.i_sum.wrapping_sub(old_i).wrapping_add(contrib.0);
self.q_sum = self.q_sum.wrapping_sub(old_q).wrapping_add(contrib.1);
*slot = contrib;
}
self.pos += 1;
if self.pos >= self.len {
self.pos = 0;
}
let i_n = self.scale(self.i_sum);
let q_n = self.scale(self.q_sum);
i_n.saturating_mul(i_n)
.saturating_add(q_n.saturating_mul(q_n))
}
}
#[derive(Debug, Clone)]
pub struct QuadratureCorrelator {
mark: ToneCorrelator,
space: ToneCorrelator,
mark_env: i64,
space_env: i64,
mark_env2: i64,
space_env2: i64,
env_shift: u32,
}
impl QuadratureCorrelator {
pub fn new(
sample_rate: SampleRate,
baud: BaudRate,
tones: TonePair,
) -> Result<Self, ConfigError> {
let sr = sample_rate.hz();
let bd = baud.bps();
let samples_per_bit = (sr / bd) as usize;
if samples_per_bit < 2 {
return Err(ConfigError::BaudExceedsSampleRate {
baud: bd,
sample_rate: sr,
});
}
let shift = tones.space_hz().abs_diff(tones.mark_hz());
let len = orthogonal_window(sr, bd, shift, samples_per_bit)
.unwrap_or(samples_per_bit)
.min(MAX_WINDOW);
let env_shift = match samples_per_bit {
0..16 => 0,
16..24 => 1,
24..32 => 2,
_ => 3,
};
Ok(Self {
mark: ToneCorrelator::new(tones.mark_hz(), sr, len),
space: ToneCorrelator::new(tones.space_hz(), sr, len),
mark_env: 0,
space_env: 0,
mark_env2: 0,
space_env2: 0,
env_shift,
})
}
pub(crate) fn push_envelopes(&mut self, sample: i32) -> (i64, i64) {
let mark_power = self.mark.push(sample);
let space_power = self.space.push(sample);
let mark_amp = (mark_power as u64).isqrt() as i64;
let space_amp = (space_power as u64).isqrt() as i64;
self.mark_env += (mark_amp - self.mark_env) >> self.env_shift;
self.space_env += (space_amp - self.space_env) >> self.env_shift;
self.mark_env2 += (self.mark_env - self.mark_env2) >> self.env_shift;
self.space_env2 += (self.space_env - self.space_env2) >> self.env_shift;
(self.mark_env2, self.space_env2)
}
fn push(&mut self, sample: i32) -> i32 {
let (mark_env, space_env) = self.push_envelopes(sample);
let diff = mark_env.saturating_sub(space_env);
diff.clamp(i32::MIN as i64, i32::MAX as i64) as i32
}
}
impl Discriminator for QuadratureCorrelator {
fn push_i16(&mut self, sample: i16) -> i32 {
self.push(sample as i32)
}
fn push_f32(&mut self, sample: f32) -> i32 {
let scaled = (sample * 32_767.0).clamp(-32_768.0, 32_767.0) as i32;
self.push(scaled)
}
}
#[cfg(test)]
mod tests {
extern crate std;
use super::*;
const RATES: [u32; 5] = [8_000, 11_025, 22_050, 44_100, 48_000];
fn bank(sr_hz: u32) -> QuadratureCorrelator {
let sr = SampleRate::new(sr_hz).unwrap();
let baud = BaudRate::new(1_200).unwrap();
let tones = TonePair::new(1_200, 2_200, sr).unwrap();
QuadratureCorrelator::new(sr, baud, tones).unwrap()
}
fn settle_tone_i16(sr_hz: u32, tone_hz: u32) -> i32 {
let mut d = bank(sr_hz);
let inc = phase_increment(tone_hz, sr_hz);
let mut phase = 0u32;
let mut metric = 0;
for _ in 0..(3 * sr_hz / 1_200) {
metric = d.push_i16(sine_at(phase));
phase = phase.wrapping_add(inc);
}
metric
}
fn settle_tone_f32(sr_hz: u32, tone_hz: u32) -> i32 {
let mut d = bank(sr_hz);
let inc = phase_increment(tone_hz, sr_hz);
let mut phase = 0u32;
let mut metric = 0;
for _ in 0..(3 * sr_hz / 1_200) {
metric = d.push_f32(sine_at(phase) as f32 / 32_767.0);
phase = phase.wrapping_add(inc);
}
metric
}
const CONFIDENCE_FLOOR: i32 = 100_000;
macro_rules! tone_tests {
($($name:ident, $fname:ident: $sr:expr;)*) => {$(
#[test]
fn $name() {
let mark = settle_tone_i16($sr, 1_200);
let space = settle_tone_i16($sr, 2_200);
assert!(mark > CONFIDENCE_FLOOR, "mark metric {mark} at {}", $sr);
assert!(space < -CONFIDENCE_FLOOR, "space metric {space} at {}", $sr);
}
#[test]
fn $fname() {
let mark = settle_tone_f32($sr, 1_200);
let space = settle_tone_f32($sr, 2_200);
assert!(mark > CONFIDENCE_FLOOR, "mark metric {mark} at {}", $sr);
assert!(space < -CONFIDENCE_FLOOR, "space metric {space} at {}", $sr);
}
)*};
}
tone_tests! {
separates_tones_i16_8000, separates_tones_f32_8000: 8_000;
separates_tones_i16_11025, separates_tones_f32_11025: 11_025;
separates_tones_i16_22050, separates_tones_f32_22050: 22_050;
separates_tones_i16_44100, separates_tones_f32_44100: 44_100;
separates_tones_i16_48000, separates_tones_f32_48000: 48_000;
}
#[test]
fn silence_gives_zero_metric() {
for sr in RATES {
let mut d = bank(sr);
let mut metric = 1;
for _ in 0..200 {
metric = d.push_i16(0);
}
assert_eq!(metric, 0, "silence metric at {sr}");
}
}
#[test]
fn metric_is_phase_independent() {
for start in [0u32, 1 << 30, 1 << 31, 3 << 30] {
let mut d = bank(48_000);
let inc = phase_increment(1_200, 48_000);
let mut phase = start;
let mut metric = 0;
for _ in 0..120 {
metric = d.push_i16(sine_at(phase));
phase = phase.wrapping_add(inc);
}
assert!(metric > CONFIDENCE_FLOOR, "phase {start}: {metric}");
}
}
#[test]
fn metric_scales_with_amplitude() {
let mut loud = bank(48_000);
let mut quiet = bank(48_000);
let inc = phase_increment(1_200, 48_000);
let mut phase = 0u32;
let (mut lm, mut qm) = (0, 0);
for _ in 0..120 {
let s = sine_at(phase);
lm = loud.push_i16(s);
qm = quiet.push_i16(s / 4);
phase = phase.wrapping_add(inc);
}
assert!(lm > qm, "loud {lm} vs quiet {qm}");
assert!(qm > 0, "quiet tone still detected: {qm}");
}
#[test]
fn rejects_one_sample_per_bit() {
let sr = SampleRate::new(8_000).unwrap();
let baud = BaudRate::new(4_800).unwrap();
let tones = TonePair::new(1_200, 2_200, sr).unwrap();
let err = QuadratureCorrelator::new(sr, baud, tones).unwrap_err();
assert_eq!(
err,
ConfigError::BaudExceedsSampleRate {
baud: 4_800,
sample_rate: 8_000
}
);
}
#[test]
fn window_matches_bit_period() {
let d = bank(48_000);
assert_eq!(d.mark.len, 40);
}
#[test]
fn window_covers_full_bit_at_300_baud() {
let sr = SampleRate::new(48_000).unwrap();
let baud = BaudRate::new(300).unwrap();
let tones = TonePair::new(1_600, 1_800, sr).unwrap();
let d = QuadratureCorrelator::new(sr, baud, tones).unwrap();
assert!(d.mark.len >= 160, "got {}", d.mark.len);
}
#[test]
fn narrow_shift_profiles_get_the_orthogonal_window() {
let baud = BaudRate::new(300).unwrap();
for (rate, want) in [(48_000u32, 240usize), (44_100, 221), (22_050, 110)] {
let sr = SampleRate::new(rate).unwrap();
for (mark, space) in [(1_600u32, 1_800u32), (1_270, 1_070), (2_225, 2_025)] {
let tones = TonePair::new(mark, space, sr).unwrap();
let d = QuadratureCorrelator::new(sr, baud, tones).unwrap();
assert_eq!(
d.mark.len, want,
"{rate} Hz, {mark}/{space}: window should be 1/shift-aligned"
);
let cycles = 200.0 * d.mark.len as f64 / f64::from(rate);
assert!(
(cycles - cycles.round()).abs() < 0.01,
"{rate} Hz: shift*T_obs = {cycles}, not orthogonal"
);
}
}
}
#[test]
fn bell_202_keeps_the_one_bit_window() {
for rate in RATES {
let sr = SampleRate::new(rate).unwrap();
let baud = BaudRate::new(1_200).unwrap();
let tones = TonePair::new(1_200, 2_200, sr).unwrap();
let d = QuadratureCorrelator::new(sr, baud, tones).unwrap();
assert_eq!(d.mark.len, (rate / 1_200) as usize, "at {rate} Hz");
}
}
#[test]
fn unfittable_orthogonal_window_falls_back_to_one_bit() {
let sr = SampleRate::new(48_000).unwrap();
let baud = BaudRate::new(300).unwrap();
let tones = TonePair::new(1_600, 1_650, sr).unwrap();
let d = QuadratureCorrelator::new(sr, baud, tones).unwrap();
assert_eq!(d.mark.len, 160);
}
#[test]
fn multiply_shift_stays_inside_its_exactness_bound() {
const S: u32 = ToneCorrelator::RECIP_SHIFT;
let mut fast = 0usize;
for len in 1..=MAX_WINDOW {
let c = ToneCorrelator::new(1_200, 48_000, len);
if c.recip == 0 {
continue; }
fast += 1;
let x_max = (len as u64) << 24;
let e = c.recip.wrapping_mul(len as u64).wrapping_sub(1u64 << S);
assert!(
e < len as u64,
"len {len}: reciprocal residue {e} must be < len"
);
assert!(
x_max.checked_mul(e).is_some_and(|p| p < (1u64 << S)),
"len {len}: x_max*e = {x_max}*{e} reaches 2^{S}; the multiply-shift is no \
longer provably exact. RECIP_MAX_LEN is too large."
);
assert!(
x_max.checked_mul(c.recip).is_some(),
"len {len}: x_max*recip overflows u64"
);
}
assert_eq!(fast, RECIP_MAX_LEN, "every len up to the cap must be fast");
}
#[test]
fn f32_path_clamps_hot_input() {
let mut d = bank(48_000);
let inc = phase_increment(1_200, 48_000);
let mut phase = 0u32;
let mut metric = 0;
for _ in 0..120 {
metric = d.push_f32(sine_at(phase) as f32); phase = phase.wrapping_add(inc);
}
assert!(metric > 0);
}
#[derive(Debug, Clone)]
struct RefToneCorrelator {
window: [(i64, i64); MAX_WINDOW],
len: usize,
pos: usize,
phase: u32,
phase_inc: u32,
i_sum: i64,
q_sum: i64,
}
impl RefToneCorrelator {
fn new(tone_hz: u32, sample_rate: u32, len: usize) -> Self {
Self {
window: [(0, 0); MAX_WINDOW],
len: len.clamp(1, MAX_WINDOW),
pos: 0,
phase: 0,
phase_inc: phase_increment(tone_hz, sample_rate),
i_sum: 0,
q_sum: 0,
}
}
fn push(&mut self, sample: i32) -> i64 {
let sin_ref = sine_at(self.phase) as i64;
let cos_ref = sine_at(self.phase.wrapping_add(ToneCorrelator::QUARTER_TURN)) as i64;
self.phase = self.phase.wrapping_add(self.phase_inc);
let contrib = ((sample as i64) * sin_ref, (sample as i64) * cos_ref);
if let Some(slot) = self.window.get_mut(self.pos) {
let (old_i, old_q) = *slot;
self.i_sum = self.i_sum.wrapping_sub(old_i).wrapping_add(contrib.0);
self.q_sum = self.q_sum.wrapping_sub(old_q).wrapping_add(contrib.1);
*slot = contrib;
}
self.pos = (self.pos + 1) % self.len.max(1);
let n = self.len as i64;
let i_n = self.i_sum / (n * 256);
let q_n = self.q_sum / (n * 256);
i_n.saturating_mul(i_n)
.saturating_add(q_n.saturating_mul(q_n))
}
}
struct Lcg(u64);
impl Lcg {
fn next_u32(&mut self) -> u32 {
self.0 = self
.0
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
(self.0 >> 32) as u32
}
}
#[test]
fn push_matches_division_reference_on_random_streams() {
for (sr, baud, win) in [
(8_000u32, 1_200u32, None),
(11_025, 1_200, None),
(22_050, 1_200, None),
(44_100, 1_200, None),
(48_000, 1_200, None),
(48_000, 300, None),
(9_600, 1_200, None),
(44_100, 300, Some(181)),
(44_100, 300, Some(182)),
(44_100, 300, Some(221)),
(48_000, 300, Some(240)),
] {
for tone in [1_200u32, 1_600, 1_800, 2_200] {
let len = win.unwrap_or((sr / baud) as usize);
let mut new = ToneCorrelator::new(tone, sr, len);
let mut old = RefToneCorrelator::new(tone, sr, len);
assert_eq!(
new.recip == 0,
len > 181,
"fast path must apply exactly up to len 181 (len {len})"
);
let mut rng = Lcg(u64::from(sr) * 31 + u64::from(tone));
for i in 0..5_000 {
let r = rng.next_u32();
let s = if i % 977 < 100 {
((r & 0x3_FFFF) as i32) - 0x2_0000
} else {
((r & 0xFFFF) as i32) - 0x8000
};
assert_eq!(
new.push(s),
old.push(s),
"divergence at sample {i} (sr {sr}, baud {baud}, tone {tone})"
);
}
}
}
}
}