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_table::{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
39use core::cmp::Ordering;
40use core::fmt::Display;
41
42/// Type-safe, closed set of DTMF keys.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
44pub enum DtmfKey {
45    K1,
46    K2,
47    K3,
48    A,
49    K4,
50    K5,
51    K6,
52    B,
53    K7,
54    K8,
55    K9,
56    C,
57    Star,
58    K0,
59    Hash,
60    D,
61}
62
63impl Display for DtmfKey {
64    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
65        if f.alternate() {
66            // Alternate format: show enum variant name
67            write!(f, "DtmfKey::{:?}", self)
68        } else {
69            // Normal format: just the character
70            write!(f, "{}", self.to_char())
71        }
72    }
73}
74
75impl DtmfKey {
76    /// Strict constructor from `char` (const).
77    pub const fn from_char(c: char) -> Option<Self> {
78        match c {
79            '1' => Some(Self::K1),
80            '2' => Some(Self::K2),
81            '3' => Some(Self::K3),
82            'A' => Some(Self::A),
83            '4' => Some(Self::K4),
84            '5' => Some(Self::K5),
85            '6' => Some(Self::K6),
86            'B' => Some(Self::B),
87            '7' => Some(Self::K7),
88            '8' => Some(Self::K8),
89            '9' => Some(Self::K9),
90            'C' => Some(Self::C),
91            '*' => Some(Self::Star),
92            '0' => Some(Self::K0),
93            '#' => Some(Self::Hash),
94            'D' => Some(Self::D),
95            _ => None,
96        }
97    }
98
99    /// Panic-on-invalid (const), useful with char literals at compile time.
100    pub const fn from_char_or_panic(c: char) -> Self {
101        match Self::from_char(c) {
102            Some(k) => k,
103            None => panic!("invalid DTMF char"),
104        }
105    }
106
107    /// Back to char (const).
108    pub const fn to_char(self) -> char {
109        match self {
110            Self::K1 => '1',
111            Self::K2 => '2',
112            Self::K3 => '3',
113            Self::A => 'A',
114            Self::K4 => '4',
115            Self::K5 => '5',
116            Self::K6 => '6',
117            Self::B => 'B',
118            Self::K7 => '7',
119            Self::K8 => '8',
120            Self::K9 => '9',
121            Self::C => 'C',
122            Self::Star => '*',
123            Self::K0 => '0',
124            Self::Hash => '#',
125            Self::D => 'D',
126        }
127    }
128
129    /// Canonical (low, high) frequencies in Hz (const).
130    pub const fn freqs(self) -> (u16, u16) {
131        match self {
132            Self::K1 => (697, 1209),
133            Self::K2 => (697, 1336),
134            Self::K3 => (697, 1477),
135            Self::A => (697, 1633),
136
137            Self::K4 => (770, 1209),
138            Self::K5 => (770, 1336),
139            Self::K6 => (770, 1477),
140            Self::B => (770, 1633),
141
142            Self::K7 => (852, 1209),
143            Self::K8 => (852, 1336),
144            Self::K9 => (852, 1477),
145            Self::C => (852, 1633),
146
147            Self::Star => (941, 1209),
148            Self::K0 => (941, 1336),
149            Self::Hash => (941, 1477),
150            Self::D => (941, 1633),
151        }
152    }
153}
154
155/// Tone record (ties the key to its canonical freqs).
156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
157pub struct DtmfTone {
158    pub key: DtmfKey,
159    pub low_hz: u16,
160    pub high_hz: u16,
161}
162
163impl Display for DtmfTone {
164    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
165        if f.alternate() {
166            // Alternate format: structured representation
167            write!(
168                f,
169                "DtmfTone {{ key: {}, low: {} Hz, high: {} Hz }}",
170                self.key, self.low_hz, self.high_hz
171            )
172        } else {
173            // Normal format: human-readable
174            write!(f, "{}: ({} Hz, {} Hz)", self.key, self.low_hz, self.high_hz)
175        }
176    }
177}
178
179/// Zero-sized table wrapper for const and runtime utilities.
180pub struct DtmfTable;
181
182impl Default for DtmfTable {
183    fn default() -> Self {
184        Self::new()
185    }
186}
187
188impl DtmfTable {
189    /// Canonical low-/high-band frequencies (Hz).
190    pub const LOWS: [u16; 4] = [697, 770, 852, 941];
191    pub const HIGHS: [u16; 4] = [1209, 1336, 1477, 1633];
192
193    /// All keys in keypad order (row-major).
194    pub const ALL_KEYS: [DtmfKey; 16] = [
195        DtmfKey::K1,
196        DtmfKey::K2,
197        DtmfKey::K3,
198        DtmfKey::A,
199        DtmfKey::K4,
200        DtmfKey::K5,
201        DtmfKey::K6,
202        DtmfKey::B,
203        DtmfKey::K7,
204        DtmfKey::K8,
205        DtmfKey::K9,
206        DtmfKey::C,
207        DtmfKey::Star,
208        DtmfKey::K0,
209        DtmfKey::Hash,
210        DtmfKey::D,
211    ];
212
213    /// All tones as (key, low, high). Kept explicit to stay `const`.
214    pub const ALL_TONES: [DtmfTone; 16] = [
215        DtmfTone {
216            key: DtmfKey::K1,
217            low_hz: 697,
218            high_hz: 1209,
219        },
220        DtmfTone {
221            key: DtmfKey::K2,
222            low_hz: 697,
223            high_hz: 1336,
224        },
225        DtmfTone {
226            key: DtmfKey::K3,
227            low_hz: 697,
228            high_hz: 1477,
229        },
230        DtmfTone {
231            key: DtmfKey::A,
232            low_hz: 697,
233            high_hz: 1633,
234        },
235        DtmfTone {
236            key: DtmfKey::K4,
237            low_hz: 770,
238            high_hz: 1209,
239        },
240        DtmfTone {
241            key: DtmfKey::K5,
242            low_hz: 770,
243            high_hz: 1336,
244        },
245        DtmfTone {
246            key: DtmfKey::K6,
247            low_hz: 770,
248            high_hz: 1477,
249        },
250        DtmfTone {
251            key: DtmfKey::B,
252            low_hz: 770,
253            high_hz: 1633,
254        },
255        DtmfTone {
256            key: DtmfKey::K7,
257            low_hz: 852,
258            high_hz: 1209,
259        },
260        DtmfTone {
261            key: DtmfKey::K8,
262            low_hz: 852,
263            high_hz: 1336,
264        },
265        DtmfTone {
266            key: DtmfKey::K9,
267            low_hz: 852,
268            high_hz: 1477,
269        },
270        DtmfTone {
271            key: DtmfKey::C,
272            low_hz: 852,
273            high_hz: 1633,
274        },
275        DtmfTone {
276            key: DtmfKey::Star,
277            low_hz: 941,
278            high_hz: 1209,
279        },
280        DtmfTone {
281            key: DtmfKey::K0,
282            low_hz: 941,
283            high_hz: 1336,
284        },
285        DtmfTone {
286            key: DtmfKey::Hash,
287            low_hz: 941,
288            high_hz: 1477,
289        },
290        DtmfTone {
291            key: DtmfKey::D,
292            low_hz: 941,
293            high_hz: 1633,
294        },
295    ];
296
297    /// Constructor (zero-sized instance).
298    pub const fn new() -> Self {
299        DtmfTable
300    }
301
302    /* ---------------------- Const utilities ---------------------- */
303
304    /// Forward: key → (low, high) (const).
305    pub const fn lookup_key(key: DtmfKey) -> (u16, u16) {
306        key.freqs()
307    }
308
309    /// Reverse: exact (low, high) → key (const). Order-sensitive.
310    pub const fn from_pair_exact(low: u16, high: u16) -> Option<DtmfKey> {
311        match (low, high) {
312            (697, 1209) => Some(DtmfKey::K1),
313            (697, 1336) => Some(DtmfKey::K2),
314            (697, 1477) => Some(DtmfKey::K3),
315            (697, 1633) => Some(DtmfKey::A),
316            (770, 1209) => Some(DtmfKey::K4),
317            (770, 1336) => Some(DtmfKey::K5),
318            (770, 1477) => Some(DtmfKey::K6),
319            (770, 1633) => Some(DtmfKey::B),
320            (852, 1209) => Some(DtmfKey::K7),
321            (852, 1336) => Some(DtmfKey::K8),
322            (852, 1477) => Some(DtmfKey::K9),
323            (852, 1633) => Some(DtmfKey::C),
324            (941, 1209) => Some(DtmfKey::Star),
325            (941, 1336) => Some(DtmfKey::K0),
326            (941, 1477) => Some(DtmfKey::Hash),
327            (941, 1633) => Some(DtmfKey::D),
328            _ => None,
329        }
330    }
331
332    /// Reverse with normalisation (const): accepts (high, low) as well.
333    pub const fn from_pair_normalised(a: u16, b: u16) -> Option<DtmfKey> {
334        let (low, high) = if a <= b { (a, b) } else { (b, a) };
335        Self::from_pair_exact(low, high)
336    }
337
338    /* ---------------------- Runtime helpers ---------------------- */
339
340    /// Iterate keys in keypad order (no allocation).
341    pub fn iter_keys(&self) -> core::slice::Iter<'static, DtmfKey> {
342        Self::ALL_KEYS.iter()
343    }
344
345    /// Iterate tones (key + freqs) in keypad order (no allocation).
346    pub fn iter_tones(&self) -> core::slice::Iter<'static, DtmfTone> {
347        Self::ALL_TONES.iter()
348    }
349
350    /// Reverse lookup with tolerance in Hz (integer inputs).
351    /// Matches only when *both* low and high fall within `±tol_hz` of a canonical pair.
352    pub fn from_pair_tol_u32(&self, low: u32, high: u32, tol_hz: u32) -> Option<DtmfKey> {
353        let (lo, hi) = normalise_u32_pair(low, high);
354        for t in Self::ALL_TONES {
355            if abs_diff_u32(lo, t.low_hz as u32) <= tol_hz
356                && abs_diff_u32(hi, t.high_hz as u32) <= tol_hz
357            {
358                return Some(t.key);
359            }
360        }
361        None
362    }
363
364    /// Reverse lookup with tolerance for floating-point estimates (e.g., FFT bin centres).
365    pub fn from_pair_tol_f64(&self, low: f64, high: f64, tol_hz: f64) -> Option<DtmfKey> {
366        let (lo, hi) = normalise_f64_pair(low, high);
367        for t in Self::ALL_TONES {
368            if (lo - t.low_hz as f64).abs() <= tol_hz && (hi - t.high_hz as f64).abs() <= tol_hz {
369                return Some(t.key);
370            }
371        }
372        None
373    }
374
375    /// Snap an arbitrary (low, high) estimate to the nearest canonical pair and return (key, snapped_low, snapped_high).
376    /// Uses absolute distance independently on low and high bands.
377    pub fn nearest_u32(&self, low: u32, high: u32) -> (DtmfKey, u16, u16) {
378        let (lo, hi) = normalise_u32_pair(low, high);
379        let nearest_low = nearest_in_set_u32(lo, &Self::LOWS);
380        let nearest_high = nearest_in_set_u32(hi, &Self::HIGHS);
381        let key = Self::from_pair_exact(nearest_low, nearest_high)
382            .expect("canonical pair must map to a key");
383        (key, nearest_low, nearest_high)
384    }
385
386    /// Floating-point variant of nearest snap.
387    pub fn nearest_f64(&self, low: f64, high: f64) -> (DtmfKey, u16, u16) {
388        let (lo, hi) = normalise_f64_pair(low, high);
389        let nearest_low = nearest_in_set_f64(lo, &Self::LOWS);
390        let nearest_high = nearest_in_set_f64(hi, &Self::HIGHS);
391        let key = Self::from_pair_exact(nearest_low, nearest_high)
392            .expect("canonical pair must map to a key");
393        (key, nearest_low, nearest_high)
394    }
395}
396
397impl Display for DtmfTable {
398    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
399        if f.alternate() {
400            // Alternate format: compact keypad grid layout
401            writeln!(f, "DTMF Keypad Layout:")?;
402            writeln!(f, "  1209 Hz  1336 Hz  1477 Hz  1633 Hz")?;
403            writeln!(f, "697 Hz:  1       2       3       A")?;
404            writeln!(f, "770 Hz:  4       5       6       B")?;
405            writeln!(f, "852 Hz:  7       8       9       C")?;
406            write!(f, "941 Hz:  *       0       #       D")
407        } else {
408            // Normal format: detailed list
409            writeln!(f, "DTMF Table:")?;
410            for tone in Self::ALL_TONES.iter() {
411                writeln!(f, "  {}", tone)?;
412            }
413            Ok(())
414        }
415    }
416}
417
418/* --------------------------- Small helpers --------------------------- */
419
420const fn abs_diff_u32(a: u32, b: u32) -> u32 {
421    a.abs_diff(b)
422}
423
424fn nearest_in_set_u32(x: u32, set: &[u16]) -> u16 {
425    let mut best = set[0];
426    let mut best_d = abs_diff_u32(x, best as u32);
427    let mut i = 1;
428    while i < set.len() {
429        let d = abs_diff_u32(x, set[i] as u32);
430        if d < best_d {
431            best = set[i];
432            best_d = d;
433        }
434        i += 1;
435    }
436    best
437}
438
439fn nearest_in_set_f64(x: f64, set: &[u16]) -> u16 {
440    let mut best = set[0];
441    let mut best_d = (x - best as f64).abs();
442    let mut i = 1;
443    while i < set.len() {
444        let d = (x - set[i] as f64).abs();
445        if d < best_d {
446            best = set[i];
447            best_d = d;
448        }
449        i += 1;
450    }
451    best
452}
453
454const fn normalise_u32_pair(a: u32, b: u32) -> (u32, u32) {
455    if a <= b { (a, b) } else { (b, a) }
456}
457
458fn normalise_f64_pair(a: f64, b: f64) -> (f64, f64) {
459    match a.partial_cmp(&b) {
460        Some(Ordering::Greater) => (b, a),
461        _ => (a, b),
462    }
463}
464
465#[cfg(test)]
466mod tests {
467    use super::*;
468
469    // These tests require std for format! macro
470    #[cfg(feature = "std")]
471    mod std_tests {
472        use super::*;
473        extern crate std;
474        use std::format;
475
476        #[test]
477        fn test_dtmf_key_normal_display() {
478            assert_eq!(format!("{}", DtmfKey::K5), "5");
479            assert_eq!(format!("{}", DtmfKey::Star), "*");
480            assert_eq!(format!("{}", DtmfKey::Hash), "#");
481            assert_eq!(format!("{}", DtmfKey::A), "A");
482        }
483
484        #[test]
485        fn test_dtmf_key_alternate_display() {
486            assert_eq!(format!("{:#}", DtmfKey::K5), "DtmfKey::K5");
487            assert_eq!(format!("{:#}", DtmfKey::Star), "DtmfKey::Star");
488            assert_eq!(format!("{:#}", DtmfKey::Hash), "DtmfKey::Hash");
489            assert_eq!(format!("{:#}", DtmfKey::A), "DtmfKey::A");
490        }
491
492        #[test]
493        fn test_dtmf_tone_normal_display() {
494            let tone = DtmfTone {
495                key: DtmfKey::K5,
496                low_hz: 770,
497                high_hz: 1336,
498            };
499            assert_eq!(format!("{}", tone), "5: (770 Hz, 1336 Hz)");
500        }
501
502        #[test]
503        fn test_dtmf_tone_alternate_display() {
504            let tone = DtmfTone {
505                key: DtmfKey::K5,
506                low_hz: 770,
507                high_hz: 1336,
508            };
509            assert_eq!(
510                format!("{:#}", tone),
511                "DtmfTone { key: 5, low: 770 Hz, high: 1336 Hz }"
512            );
513        }
514
515        #[test]
516        fn test_dtmf_table_normal_display() {
517            let table = DtmfTable::new();
518            let output = format!("{}", table);
519            assert!(output.contains("DTMF Table:"));
520            assert!(output.contains("1: (697 Hz, 1209 Hz)"));
521            assert!(output.contains("5: (770 Hz, 1336 Hz)"));
522            assert!(output.contains("D: (941 Hz, 1633 Hz)"));
523        }
524
525        #[test]
526        fn test_dtmf_table_alternate_display() {
527            let table = DtmfTable::new();
528            let output = format!("{:#}", table);
529            assert!(output.contains("DTMF Keypad Layout:"));
530            assert!(output.contains("1209 Hz"));
531            assert!(output.contains("697 Hz:"));
532            assert!(output.contains("941 Hz:"));
533            // Check that all keys are present in the grid
534            assert!(output.contains("1"));
535            assert!(output.contains("5"));
536            assert!(output.contains("*"));
537            assert!(output.contains("#"));
538        }
539
540        #[test]
541        fn test_all_keys_have_alternate_format() {
542            // Verify all keys can be formatted with alternate format
543            for key in DtmfTable::ALL_KEYS.iter() {
544                let normal = format!("{}", key);
545                let alternate = format!("{:#}", key);
546
547                // Normal should be single character
548                assert_eq!(normal.len(), 1);
549
550                // Alternate should contain "DtmfKey::"
551                assert!(alternate.starts_with("DtmfKey::"));
552
553                // They should be different
554                assert_ne!(normal, alternate);
555            }
556        }
557
558        #[test]
559        fn test_all_tones_have_alternate_format() {
560            // Verify all tones can be formatted with alternate format
561            for tone in DtmfTable::ALL_TONES.iter() {
562                let normal = format!("{}", tone);
563                let alternate = format!("{:#}", tone);
564
565                // Normal should contain "Hz"
566                assert!(normal.contains("Hz"));
567
568                // Alternate should contain "DtmfTone"
569                assert!(alternate.contains("DtmfTone"));
570
571                // They should be different
572                assert_ne!(normal, alternate);
573            }
574        }
575    }
576}