use crate::ambe_plus2_wire::frame::ANNEX_T;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ToneDetection {
pub id: u8,
pub amplitude: u8,
}
pub const TONE_DETECT_FRAME: usize = 160;
const FFT_SIZE: usize = 256;
const SAMPLE_RATE_HZ: f64 = 8000.0;
const MATCH_TOLERANCE_HZ: f64 = 20.0;
const SNR_THRESHOLD: f64 = 100.0;
fn hann_window() -> &'static [f64; FFT_SIZE] {
use std::sync::OnceLock;
static WINDOW: OnceLock<[f64; FFT_SIZE]> = OnceLock::new();
WINDOW.get_or_init(|| {
let two_pi = 2.0 * core::f64::consts::PI;
let mut w = [0.0f64; FFT_SIZE];
for n in 0..TONE_DETECT_FRAME {
w[n] = 0.5 - 0.5 * (two_pi * n as f64 / TONE_DETECT_FRAME as f64).cos();
}
w
})
}
fn compute_psd(pcm: &[i16; TONE_DETECT_FRAME]) -> ([f64; FFT_SIZE / 2 + 1], f64) {
use std::sync::OnceLock;
use num_complex::Complex;
use rustfft::{Fft, FftPlanner};
static FFT: OnceLock<std::sync::Arc<dyn Fft<f64>>> = OnceLock::new();
let fft = FFT.get_or_init(|| {
let mut planner = FftPlanner::<f64>::new();
planner.plan_fft_forward(FFT_SIZE)
});
let window = hann_window();
let mut buf = [Complex::<f64>::new(0.0, 0.0); FFT_SIZE];
for n in 0..TONE_DETECT_FRAME {
buf[n] = Complex::new(pcm[n] as f64 * window[n], 0.0);
}
fft.process(&mut buf);
let mut psd = [0.0f64; FFT_SIZE / 2 + 1];
for k in 0..=FFT_SIZE / 2 {
psd[k] = buf[k].re * buf[k].re + buf[k].im * buf[k].im;
}
let mut samples = [0.0f64; FFT_SIZE / 2];
samples.copy_from_slice(&psd[1..]);
let idx = samples.len() / 4;
samples.select_nth_unstable_by(idx, |a, b| {
a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal)
});
let floor = samples[idx];
(psd, floor)
}
fn interpolate_peak_hz(psd: &[f64], k: usize) -> f64 {
if k > 0 && k + 1 < psd.len() {
let y_m1 = psd[k - 1].sqrt();
let y_0 = psd[k].sqrt();
let y_p1 = psd[k + 1].sqrt();
let denom = y_m1 - 2.0 * y_0 + y_p1;
let delta = if denom.abs() > 1e-12 {
0.5 * (y_m1 - y_p1) / denom
} else {
0.0
};
(k as f64 + delta) * SAMPLE_RATE_HZ / FFT_SIZE as f64
} else {
k as f64 * SAMPLE_RATE_HZ / FFT_SIZE as f64
}
}
const REF_PEAK_MAG: f64 = 1_310_000.0;
const DB_PER_AD_STEP: f64 = 0.7115;
fn magnitude_to_amplitude(mag: f64) -> u8 {
let amp_db = 20.0 * (mag / REF_PEAK_MAG).log10();
(127.0 + amp_db / DB_PER_AD_STEP).round().clamp(0.0, 127.0) as u8
}
pub fn detect_single_tone(pcm: &[i16]) -> Option<ToneDetection> {
if pcm.len() != TONE_DETECT_FRAME {
return None;
}
let pcm: &[i16; TONE_DETECT_FRAME] = pcm.try_into().ok()?;
let (psd, floor) = compute_psd(pcm);
let mut peak_k = 1usize;
let mut peak_p = psd[1];
for (k, &p) in psd.iter().enumerate().skip(2) {
if p > peak_p {
peak_p = p;
peak_k = k;
}
}
if floor <= 0.0 || peak_p / floor < SNR_THRESHOLD {
return None;
}
let f_hz = interpolate_peak_hz(&psd, peak_k);
let mut best: Option<(usize, f64)> = None;
for (id, entry) in ANNEX_T.iter().enumerate() {
let Some(t) = entry else { continue };
if t.l1 != t.l2 {
continue;
}
let expected_hz = f64::from(t.l1) * f64::from(t.f0);
let diff = (f_hz - expected_hz).abs();
if diff < MATCH_TOLERANCE_HZ
&& best.map_or(true, |(_, prev)| diff < prev)
{
best = Some((id, diff));
}
}
let id = best?.0 as u8;
Some(ToneDetection { id, amplitude: magnitude_to_amplitude(peak_p.sqrt()) })
}
pub fn detect_dtmf(pcm: &[i16]) -> Option<ToneDetection> {
if pcm.len() != TONE_DETECT_FRAME {
return None;
}
let pcm: &[i16; TONE_DETECT_FRAME] = pcm.try_into().ok()?;
let (psd, floor) = compute_psd(pcm);
let mut peak1_k = 1usize;
let mut peak1_p = psd[1];
for (k, &p) in psd.iter().enumerate().skip(2) {
if p > peak1_p {
peak1_p = p;
peak1_k = k;
}
}
if floor <= 0.0 || peak1_p / floor < SNR_THRESHOLD {
return None;
}
let mut peak2_k: Option<usize> = None;
let mut peak2_p = floor; for (k, &p) in psd.iter().enumerate().skip(1) {
if k.abs_diff(peak1_k) < 3 {
continue;
}
if p > peak2_p {
peak2_p = p;
peak2_k = Some(k);
}
}
let peak2_k = peak2_k?;
if peak2_p / floor < SNR_THRESHOLD {
return None;
}
let f1_hz = interpolate_peak_hz(&psd, peak1_k);
let f2_hz = interpolate_peak_hz(&psd, peak2_k);
let f_lo = f1_hz.min(f2_hz);
let f_hi = f1_hz.max(f2_hz);
let mut best: Option<(usize, f64)> = None;
for (id, entry) in ANNEX_T.iter().enumerate() {
let Some(t) = entry else { continue };
if t.l1 == t.l2 {
continue; }
let f0 = f64::from(t.f0);
let l_lo = t.l1.min(t.l2) as f64;
let l_hi = t.l1.max(t.l2) as f64;
let expected_lo = l_lo * f0;
let expected_hi = l_hi * f0;
let err = (expected_lo - f_lo).abs() + (expected_hi - f_hi).abs();
if err < 2.0 * MATCH_TOLERANCE_HZ
&& best.map_or(true, |(_, prev)| err < prev)
{
best = Some((id, err));
}
}
let id = best?.0 as u8;
let mag = peak1_p.sqrt().max(peak2_p.sqrt());
Some(ToneDetection { id, amplitude: magnitude_to_amplitude(mag) })
}
pub fn detect_tone(pcm: &[i16]) -> Option<ToneDetection> {
detect_dtmf(pcm).or_else(|| detect_single_tone(pcm))
}
#[cfg(test)]
mod tests {
use super::*;
fn pure_sine(freq_hz: f64, amplitude: i16) -> [i16; TONE_DETECT_FRAME] {
let mut out = [0i16; TONE_DETECT_FRAME];
let two_pi = 2.0 * core::f64::consts::PI;
for (n, slot) in out.iter_mut().enumerate() {
let s = (amplitude as f64) * (two_pi * freq_hz * n as f64 / SAMPLE_RATE_HZ).sin();
*slot = s.round() as i16;
}
out
}
fn dtmf_pair(f1: f64, f2: f64, amp_each: i16) -> [i16; TONE_DETECT_FRAME] {
let mut out = [0i16; TONE_DETECT_FRAME];
let two_pi = 2.0 * core::f64::consts::PI;
for (n, slot) in out.iter_mut().enumerate() {
let s1 = (amp_each as f64) * (two_pi * f1 * n as f64 / SAMPLE_RATE_HZ).sin();
let s2 = (amp_each as f64) * (two_pi * f2 * n as f64 / SAMPLE_RATE_HZ).sin();
*slot = (s1 + s2).round().clamp(-32768.0, 32767.0) as i16;
}
out
}
#[test]
fn detects_clean_sine_at_annex_t_frequency() {
let pcm = pure_sine(312.5, 8000);
let det = detect_single_tone(&pcm).expect("clean tone should be detected");
assert_eq!(det.id, 10);
assert!(det.amplitude > 50, "amplitude clamped to >0: {}", det.amplitude);
}
#[test]
fn rejects_white_noise() {
let mut pcm = [0i16; TONE_DETECT_FRAME];
let mut state: u32 = 12345;
for slot in pcm.iter_mut() {
state = state.wrapping_mul(1664525).wrapping_add(1013904223);
*slot = ((state as i32 >> 16) as i16) / 4;
}
assert!(detect_single_tone(&pcm).is_none());
}
#[test]
fn rejects_silence() {
let pcm = [0i16; TONE_DETECT_FRAME];
assert!(detect_single_tone(&pcm).is_none());
}
#[test]
fn snr_floor_rejects_weak_tone_buried_in_noise() {
let mut pcm = pure_sine(312.5, 1000);
let mut state: u32 = 7777;
for slot in pcm.iter_mut() {
state = state.wrapping_mul(1664525).wrapping_add(1013904223);
let n = ((state as i32 >> 16) as i16) / 4;
*slot = slot.saturating_add(n);
}
let _ = detect_single_tone(&pcm);
}
#[test]
fn wrong_length_returns_none() {
let pcm = [0i16; 100];
assert!(detect_single_tone(&pcm).is_none());
}
#[test]
fn tolerance_window_snaps_to_nearest_annex_entry() {
let pcm = pure_sine(316.0, 8000);
let det = detect_single_tone(&pcm).expect("near-match should detect");
assert_eq!(det.id, 10);
}
#[test]
fn detects_standard_motorola_test_tone_at_l3_harmonic() {
let pcm = pure_sine(1031.25, 8000);
let det = detect_single_tone(&pcm).expect("1031 Hz tone should match an l=3 row");
let entry = ANNEX_T[det.id as usize].expect("matched id has Annex T entry");
assert_eq!(entry.l1, entry.l2, "single-tone row");
assert_eq!(entry.l1, 3, "1031 Hz is in the l=3 band of Annex T");
let expected_hz = f64::from(entry.l1) * f64::from(entry.f0);
assert!(
(expected_hz - 1031.25).abs() < f64::from(MATCH_TOLERANCE_HZ as f32),
"l·f0 ({expected_hz:.2}) should be within tolerance of 1031.25"
);
}
#[test]
fn detect_dtmf_matches_known_annex_t_pair() {
let pcm = dtmf_pair(942.0, 1334.5, 6000);
let det = detect_dtmf(&pcm).expect("clean DTMF should be detected");
assert_eq!(det.id, 128);
}
#[test]
fn detect_tone_prefers_dtmf_when_two_peaks_present() {
let pcm = dtmf_pair(942.0, 1334.5, 6000);
let det = detect_tone(&pcm).expect("tone-detect dispatch hit");
assert_eq!(det.id, 128);
}
#[test]
fn detect_dtmf_rejects_single_tone_input() {
let pcm = pure_sine(312.5, 8000);
assert!(detect_dtmf(&pcm).is_none());
let det = detect_tone(&pcm).expect("falls through to single-tone");
assert_eq!(det.id, 10);
}
#[test]
fn detect_dtmf_rejects_noise() {
let mut pcm = [0i16; TONE_DETECT_FRAME];
let mut state: u32 = 9999;
for slot in pcm.iter_mut() {
state = state.wrapping_mul(1664525).wrapping_add(1013904223);
*slot = ((state as i32 >> 16) as i16) / 4;
}
assert!(detect_dtmf(&pcm).is_none());
}
#[test]
fn detect_dtmf_round_trips_each_knox_id() {
for knox_id in 144u8..=159 {
let entry = ANNEX_T[knox_id as usize].expect("Knox ID has Annex T row");
assert_ne!(entry.l1, entry.l2, "Knox IDs are pairs, l1 != l2");
let f0 = f64::from(entry.f0);
let f_lo = f0 * f64::from(entry.l1.min(entry.l2));
let f_hi = f0 * f64::from(entry.l1.max(entry.l2));
if f_lo < 150.0 || f_hi > 3500.0 {
continue;
}
let pcm = dtmf_pair(f_lo, f_hi, 6000);
let det = detect_dtmf(&pcm)
.unwrap_or_else(|| panic!("Knox ID {knox_id} should detect"));
assert_eq!(
det.id, knox_id,
"Knox ID {knox_id} (f_lo={f_lo:.1} Hz, f_hi={f_hi:.1} Hz) misdetected as {}",
det.id
);
}
}
#[test]
fn detect_tone_routes_knox_pairs_through_dtmf_matcher() {
let entry = ANNEX_T[145].unwrap();
let f_lo = f64::from(entry.f0) * f64::from(entry.l1.min(entry.l2));
let f_hi = f64::from(entry.f0) * f64::from(entry.l1.max(entry.l2));
let pcm = dtmf_pair(f_lo, f_hi, 6000);
let det = detect_tone(&pcm).expect("Knox pair detect");
assert_eq!(det.id, 145);
}
}