dtmf_table/
lib.rs

1#![cfg_attr(not(feature = "std"), no_std)]
2//! # DTMF Table
3//!
4//! A zero-heap, `no_std`, const-first implementation of the standard DTMF keypad
5//! frequencies with ergonomic runtime helpers for real-world audio decoding.
6//!
7//! ## Features
8//! - Type-safe closed enum for DTMF keys — invalid keys are unrepresentable.
9//! - Fully `const` forward and reverse mappings (key ↔ frequencies).
10//! - Runtime helpers for tolerance-based reverse lookup and nearest snapping.
11//! - No heap, no allocations, no dependencies.
12//!
13//! ## Example
14//!
15//! ```rust
16//! use dtmf_tones::{DtmfTable, DtmfKey};
17//!
18//! // Construct a zero-sized table instance
19//! let table = DtmfTable::new();
20//!
21//! // Forward lookup from key to canonical frequencies
22//! let (low, high) = DtmfTable::lookup_key(DtmfKey::K8);
23//! assert_eq!((low, high), (852, 1336));
24//!
25//! // Reverse lookup with tolerance (e.g. from FFT bin centres)
26//! let key = table.from_pair_tol_f64(770.2, 1335.6, 6.0).unwrap();
27//! assert_eq!(key.to_char(), '5');
28//!
29//! // Nearest snapping for noisy estimates
30//! let (k, snapped_low, snapped_high) = table.nearest_u32(768, 1342);
31//! assert_eq!(k.to_char(), '5');
32//! assert_eq!((snapped_low, snapped_high), (770, 1336));
33//! ```
34//!
35//! This makes it easy to integrate DTMF tone detection directly into audio
36//! processing pipelines (e.g., FFT bin peak picking) with robust tolerance handling
37//! and compile-time validation of key mappings.
38
39
40use core::cmp::Ordering;
41use core::fmt::Display;
42
43/// Type-safe, closed set of DTMF keys.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
45pub enum DtmfKey {
46    K1, K2, K3, A,
47    K4, K5, K6, B,
48    K7, K8, K9, C,
49    Star, K0, Hash, D,
50}
51
52impl Display for DtmfKey {
53    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
54        write!(f, "{}", self.to_char())
55    }
56}
57
58impl DtmfKey {
59    /// Strict constructor from `char` (const).
60    pub const fn from_char(c: char) -> Option<Self> {
61        match c {
62            '1' => Some(Self::K1),
63            '2' => Some(Self::K2),
64            '3' => Some(Self::K3),
65            'A' => Some(Self::A),
66            '4' => Some(Self::K4),
67            '5' => Some(Self::K5),
68            '6' => Some(Self::K6),
69            'B' => Some(Self::B),
70            '7' => Some(Self::K7),
71            '8' => Some(Self::K8),
72            '9' => Some(Self::K9),
73            'C' => Some(Self::C),
74            '*' => Some(Self::Star),
75            '0' => Some(Self::K0),
76            '#' => Some(Self::Hash),
77            'D' => Some(Self::D),
78            _ => None,
79        }
80    }
81
82    /// Panic-on-invalid (const), useful with char literals at compile time.
83    pub const fn from_char_or_panic(c: char) -> Self {
84        match Self::from_char(c) {
85            Some(k) => k,
86            None => panic!("invalid DTMF char"),
87        }
88    }
89
90    /// Back to char (const).
91    pub const fn to_char(self) -> char {
92        match self {
93            Self::K1 => '1', Self::K2 => '2', Self::K3 => '3', Self::A => 'A',
94            Self::K4 => '4', Self::K5 => '5', Self::K6 => '6', Self::B => 'B',
95            Self::K7 => '7', Self::K8 => '8', Self::K9 => '9', Self::C => 'C',
96            Self::Star => '*', Self::K0 => '0', Self::Hash => '#', Self::D => 'D',
97        }
98    }
99
100    /// Canonical (low, high) frequencies in Hz (const).
101    pub const fn freqs(self) -> (u16, u16) {
102        match self {
103            Self::K1 => (697, 1209),
104            Self::K2 => (697, 1336),
105            Self::K3 => (697, 1477),
106            Self::A  => (697, 1633),
107
108            Self::K4 => (770, 1209),
109            Self::K5 => (770, 1336),
110            Self::K6 => (770, 1477),
111            Self::B  => (770, 1633),
112
113            Self::K7 => (852, 1209),
114            Self::K8 => (852, 1336),
115            Self::K9 => (852, 1477),
116            Self::C  => (852, 1633),
117
118            Self::Star => (941, 1209),
119            Self::K0   => (941, 1336),
120            Self::Hash => (941, 1477),
121            Self::D    => (941, 1633),
122        }
123    }
124}
125
126/// Tone record (ties the key to its canonical freqs).
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub struct DtmfTone {
129    pub key: DtmfKey,
130    pub low_hz: u16,
131    pub high_hz: u16,
132}
133
134impl Display for DtmfTone {
135    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
136        write!(f, "{}: ({} Hz, {} Hz)", self.key, self.low_hz, self.high_hz)
137    }
138}
139
140/// Zero-sized table wrapper for const and runtime utilities.
141pub struct DtmfTable;
142
143impl DtmfTable {
144    /// Canonical low-/high-band frequencies (Hz).
145    pub const LOWS: [u16; 4]  = [697, 770, 852, 941];
146    pub const HIGHS: [u16; 4] = [1209, 1336, 1477, 1633];
147
148    /// All keys in keypad order (row-major).
149    pub const ALL_KEYS: [DtmfKey; 16] = [
150        DtmfKey::K1, DtmfKey::K2, DtmfKey::K3, DtmfKey::A,
151        DtmfKey::K4, DtmfKey::K5, DtmfKey::K6, DtmfKey::B,
152        DtmfKey::K7, DtmfKey::K8, DtmfKey::K9, DtmfKey::C,
153        DtmfKey::Star, DtmfKey::K0, DtmfKey::Hash, DtmfKey::D,
154    ];
155
156    /// All tones as (key, low, high). Kept explicit to stay `const`.
157    pub const ALL_TONES: [DtmfTone; 16] = [
158        DtmfTone { key: DtmfKey::K1,   low_hz: 697, high_hz: 1209 },
159        DtmfTone { key: DtmfKey::K2,   low_hz: 697, high_hz: 1336 },
160        DtmfTone { key: DtmfKey::K3,   low_hz: 697, high_hz: 1477 },
161        DtmfTone { key: DtmfKey::A,    low_hz: 697, high_hz: 1633 },
162
163        DtmfTone { key: DtmfKey::K4,   low_hz: 770, high_hz: 1209 },
164        DtmfTone { key: DtmfKey::K5,   low_hz: 770, high_hz: 1336 },
165        DtmfTone { key: DtmfKey::K6,   low_hz: 770, high_hz: 1477 },
166        DtmfTone { key: DtmfKey::B,    low_hz: 770, high_hz: 1633 },
167
168        DtmfTone { key: DtmfKey::K7,   low_hz: 852, high_hz: 1209 },
169        DtmfTone { key: DtmfKey::K8,   low_hz: 852, high_hz: 1336 },
170        DtmfTone { key: DtmfKey::K9,   low_hz: 852, high_hz: 1477 },
171        DtmfTone { key: DtmfKey::C,    low_hz: 852, high_hz: 1633 },
172
173        DtmfTone { key: DtmfKey::Star, low_hz: 941, high_hz: 1209 },
174        DtmfTone { key: DtmfKey::K0,   low_hz: 941, high_hz: 1336 },
175        DtmfTone { key: DtmfKey::Hash, low_hz: 941, high_hz: 1477 },
176        DtmfTone { key: DtmfKey::D,    low_hz: 941, high_hz: 1633 },
177    ];
178
179    /// Constructor (zero-sized instance).
180    pub const fn new() -> Self { DtmfTable }
181
182    /* ---------------------- Const utilities ---------------------- */
183
184    /// Forward: key → (low, high) (const).
185    pub const fn lookup_key(key: DtmfKey) -> (u16, u16) { key.freqs() }
186
187    /// Reverse: exact (low, high) → key (const). Order-sensitive.
188    pub const fn from_pair_exact(low: u16, high: u16) -> Option<DtmfKey> {
189        match (low, high) {
190            (697, 1209) => Some(DtmfKey::K1),
191            (697, 1336) => Some(DtmfKey::K2),
192            (697, 1477) => Some(DtmfKey::K3),
193            (697, 1633) => Some(DtmfKey::A),
194            (770, 1209) => Some(DtmfKey::K4),
195            (770, 1336) => Some(DtmfKey::K5),
196            (770, 1477) => Some(DtmfKey::K6),
197            (770, 1633) => Some(DtmfKey::B),
198            (852, 1209) => Some(DtmfKey::K7),
199            (852, 1336) => Some(DtmfKey::K8),
200            (852, 1477) => Some(DtmfKey::K9),
201            (852, 1633) => Some(DtmfKey::C),
202            (941, 1209) => Some(DtmfKey::Star),
203            (941, 1336) => Some(DtmfKey::K0),
204            (941, 1477) => Some(DtmfKey::Hash),
205            (941, 1633) => Some(DtmfKey::D),
206            _ => None,
207        }
208    }
209
210    /// Reverse with normalisation (const): accepts (high, low) as well.
211    pub const fn from_pair_normalised(a: u16, b: u16) -> Option<DtmfKey> {
212        let (low, high) = if a <= b { (a, b) } else { (b, a) };
213        Self::from_pair_exact(low, high)
214    }
215
216    /* ---------------------- Runtime helpers ---------------------- */
217
218    /// Iterate keys in keypad order (no allocation).
219    pub fn iter_keys(&self) -> core::slice::Iter<'static, DtmfKey> {
220        Self::ALL_KEYS.iter()
221    }
222
223    /// Iterate tones (key + freqs) in keypad order (no allocation).
224    pub fn iter_tones(&self) -> core::slice::Iter<'static, DtmfTone> {
225        Self::ALL_TONES.iter()
226    }
227
228    /// Reverse lookup with tolerance in Hz (integer inputs).
229    /// Matches only when *both* low and high fall within `±tol_hz` of a canonical pair.
230    pub fn from_pair_tol_u32(&self, low: u32, high: u32, tol_hz: u32) -> Option<DtmfKey> {
231        let (lo, hi) = normalise_u32_pair(low, high);
232        for t in Self::ALL_TONES {
233            if abs_diff_u32(lo, t.low_hz as u32) <= tol_hz &&
234               abs_diff_u32(hi, t.high_hz as u32) <= tol_hz {
235                return Some(t.key);
236            }
237        }
238        None
239    }
240
241    /// Reverse lookup with tolerance for floating-point estimates (e.g., FFT bin centres).
242    pub fn from_pair_tol_f64(&self, low: f64, high: f64, tol_hz: f64) -> Option<DtmfKey> {
243        let (lo, hi) = normalise_f64_pair(low, high);
244        for t in Self::ALL_TONES {
245            if (lo - t.low_hz as f64).abs() <= tol_hz &&
246               (hi - t.high_hz as f64).abs() <= tol_hz {
247                return Some(t.key);
248            }
249        }
250        None
251    }
252
253    /// Snap an arbitrary (low, high) estimate to the nearest canonical pair and return (key, snapped_low, snapped_high).
254    /// Uses absolute distance independently on low and high bands.
255    pub fn nearest_u32(&self, low: u32, high: u32) -> (DtmfKey, u16, u16) {
256        let (lo, hi) = normalise_u32_pair(low, high);
257        let nearest_low  = nearest_in_set_u32(lo,  &Self::LOWS);
258        let nearest_high = nearest_in_set_u32(hi, &Self::HIGHS);
259        let key = Self::from_pair_exact(nearest_low, nearest_high)
260            .expect("canonical pair must map to a key");
261        (key, nearest_low, nearest_high)
262    }
263
264    /// Floating-point variant of nearest snap.
265    pub fn nearest_f64(&self, low: f64, high: f64) -> (DtmfKey, u16, u16) {
266        let (lo, hi) = normalise_f64_pair(low, high);
267        let nearest_low  = nearest_in_set_f64(lo,  &Self::LOWS);
268        let nearest_high = nearest_in_set_f64(hi, &Self::HIGHS);
269        let key = Self::from_pair_exact(nearest_low, nearest_high)
270            .expect("canonical pair must map to a key");
271        (key, nearest_low, nearest_high)
272    }
273}
274
275impl Display for DtmfTable {
276    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
277        writeln!(f, "DTMF Table:")?;
278        for tone in Self::ALL_TONES.iter() {
279            writeln!(f, "  {}", tone)?;
280        }
281        Ok(())
282    }
283}
284
285/* --------------------------- Small helpers --------------------------- */
286
287const fn abs_diff_u32(a: u32, b: u32) -> u32 {
288    if a >= b { a - b } else { b - a }
289}
290
291fn nearest_in_set_u32(x: u32, set: &[u16]) -> u16 {
292    let mut best = set[0];
293    let mut best_d = abs_diff_u32(x, best as u32);
294    let mut i = 1;
295    while i < set.len() {
296        let d = abs_diff_u32(x, set[i] as u32);
297        if d < best_d { best = set[i]; best_d = d; }
298        i += 1;
299    }
300    best
301}
302
303fn nearest_in_set_f64(x: f64, set: &[u16]) -> u16 {
304    let mut best = set[0];
305    let mut best_d = (x - best as f64).abs();
306    let mut i = 1;
307    while i < set.len() {
308        let d = (x - set[i] as f64).abs();
309        if d < best_d { best = set[i]; best_d = d; }
310        i += 1;
311    }
312    best
313}
314
315const fn normalise_u32_pair(a: u32, b: u32) -> (u32, u32) {
316    if a <= b { (a, b) } else { (b, a) }
317}
318
319fn normalise_f64_pair(a: f64, b: f64) -> (f64, f64) {
320    match a.partial_cmp(&b) {
321        Some(Ordering::Greater) => (b, a),
322        _ => (a, b),
323    }
324}