Skip to main content

gemini_adk_fluent_rs/telephony/
dtmf.rs

1//! In-band DTMF: keypad tones found in the call audio itself.
2//!
3//! Carriers deliver keypresses in two ways. Out of band, as RFC 4733
4//! telephone-events or a platform's `dtmf` message; those are handled by the
5//! SIP and Twilio bridges. Or in band, as the dual tones themselves inside
6//! the audio: a SIP trunk without telephone-events, a transferred call, or a
7//! caller pressing keys while the platform forwards raw media.
8//!
9//! [`DtmfDetector`] finds in-band tones with the Goertzel algorithm over the
10//! eight DTMF frequencies, in blocks of 25.6 ms (205 samples at 8 kHz), with
11//! the checks ITU-T Q.24 receivers use: both a row and a column tone must be
12//! strong, dominate their groups, sit within 8 dB of each other, and carry
13//! most of the block's energy, which is what keeps speech and music from
14//! registering as digits.
15
16const ROWS: [f64; 4] = [697.0, 770.0, 852.0, 941.0];
17const COLS: [f64; 4] = [1209.0, 1336.0, 1477.0, 1633.0];
18const KEYS: [[char; 4]; 4] = [
19    ['1', '2', '3', 'A'],
20    ['4', '5', '6', 'B'],
21    ['7', '8', '9', 'C'],
22    ['*', '0', '#', 'D'],
23];
24/// Block length in seconds (205 samples at 8 kHz).
25const BLOCK_SECS: f64 = 0.025_625;
26/// 8 dB as a power ratio: the most the row and column tones may differ
27/// (twist), and the least by which each must beat the rest of its group.
28const EIGHT_DB: f64 = 6.31;
29/// Share of the block's energy the two tones must carry.
30const PURITY: f64 = 0.6;
31/// Quietest tone accepted: RMS of about -36 dBFS.
32const MIN_MEAN_SQUARE: f64 = 500.0 * 500.0;
33
34/// Streaming in-band DTMF detector. See the module docs.
35#[derive(Debug, Clone)]
36pub struct DtmfDetector {
37    block: usize,
38    coeffs: [f64; 8],
39    buffer: Vec<i16>,
40    /// The digit the last complete block held, for edge detection.
41    last: Option<char>,
42}
43
44impl DtmfDetector {
45    /// A detector for mono PCM16 at `sample_rate` (8 kHz on the phone
46    /// network; any rate of 4 kHz or more works).
47    pub fn new(sample_rate: u32) -> Self {
48        let rate = f64::from(sample_rate.max(4_000));
49        let block = (rate * BLOCK_SECS).round() as usize;
50        let mut coeffs = [0.0; 8];
51        for (i, f) in ROWS.iter().chain(COLS.iter()).enumerate() {
52            let k = (block as f64 * f / rate).round();
53            coeffs[i] = 2.0 * (2.0 * std::f64::consts::PI * k / block as f64).cos();
54        }
55        Self {
56            block,
57            coeffs,
58            buffer: Vec::with_capacity(block),
59            last: None,
60        }
61    }
62
63    /// Feed audio; returns each keypress that began in it, in order. A held
64    /// key is reported once.
65    pub fn feed(&mut self, samples: &[i16]) -> Vec<char> {
66        let mut digits = Vec::new();
67        for &sample in samples {
68            self.buffer.push(sample);
69            if self.buffer.len() == self.block {
70                let digit = self.classify();
71                if digit.is_some() && digit != self.last {
72                    digits.extend(digit);
73                }
74                self.last = digit;
75                self.buffer.clear();
76            }
77        }
78        digits
79    }
80
81    /// Whether the last complete block held a keypad tone.
82    pub fn tone_present(&self) -> bool {
83        self.last.is_some()
84    }
85
86    fn classify(&self) -> Option<char> {
87        let n = self.buffer.len() as f64;
88        let energy: f64 = self.buffer.iter().map(|&s| f64::from(s).powi(2)).sum();
89        if energy / n < MIN_MEAN_SQUARE {
90            return None;
91        }
92        let mut power = [0f64; 8];
93        for (i, &coeff) in self.coeffs.iter().enumerate() {
94            let (mut s1, mut s2) = (0f64, 0f64);
95            for &x in &self.buffer {
96                let s0 = f64::from(x) + coeff * s1 - s2;
97                s2 = s1;
98                s1 = s0;
99            }
100            power[i] = s1 * s1 + s2 * s2 - coeff * s1 * s2;
101        }
102        let strongest = |group: &[f64]| {
103            let (best, &p) = group
104                .iter()
105                .enumerate()
106                .max_by(|a, b| a.1.total_cmp(b.1))
107                .expect("four tones");
108            let runner_up = group
109                .iter()
110                .enumerate()
111                .filter(|(i, _)| *i != best)
112                .map(|(_, &q)| q)
113                .fold(0.0, f64::max);
114            (best, p, runner_up)
115        };
116        let (row, row_power, row_next) = strongest(&power[..4]);
117        let (col, col_power, col_next) = strongest(&power[4..]);
118        let dominant = row_power > row_next * EIGHT_DB && col_power > col_next * EIGHT_DB;
119        let twist_ok = row_power < col_power * EIGHT_DB && col_power < row_power * EIGHT_DB;
120        // A Goertzel power of P for a full-block sine means P * 2 / n of
121        // that sine's energy over the block.
122        let tone_energy = (row_power + col_power) * 2.0 / n;
123        let pure = tone_energy > PURITY * energy;
124        (dominant && twist_ok && pure).then_some(KEYS[row][col])
125    }
126}
127
128#[cfg(test)]
129pub(crate) mod tests {
130    use super::*;
131
132    /// `digits` as tones of `on_ms`, separated by `off_ms` of silence, at 8 kHz.
133    pub(crate) fn keypresses(digits: &str, on_ms: usize, off_ms: usize, level: f64) -> Vec<i16> {
134        let rate = 8_000.0;
135        let mut out = Vec::new();
136        for digit in digits.chars() {
137            let (r, c) = KEYS
138                .iter()
139                .enumerate()
140                .find_map(|(r, row)| row.iter().position(|&k| k == digit).map(|c| (r, c)))
141                .expect("a keypad digit");
142            for n in 0..on_ms * 8 {
143                let t = n as f64 / rate;
144                let v = level
145                    * ((2.0 * std::f64::consts::PI * ROWS[r] * t).sin()
146                        + (2.0 * std::f64::consts::PI * COLS[c] * t).sin());
147                out.push(v as i16);
148            }
149            out.extend(std::iter::repeat_n(0, off_ms * 8));
150        }
151        out
152    }
153
154    #[test]
155    fn keypresses_are_detected_once_each() {
156        let audio = keypresses("4129#*0D", 70, 50, 6_000.0);
157        let mut detector = DtmfDetector::new(8_000);
158        let mut found = String::new();
159        // In 20 ms frames, as a phone bridge delivers them.
160        for frame in audio.chunks(160) {
161            found.extend(detector.feed(frame));
162        }
163        assert_eq!(found, "4129#*0D");
164    }
165
166    #[test]
167    fn a_held_key_is_one_press_and_repeats_need_a_gap() {
168        let mut detector = DtmfDetector::new(8_000);
169        let held = keypresses("5", 400, 60, 6_000.0);
170        assert_eq!(detector.feed(&held), ['5']);
171        let again = keypresses("55", 60, 60, 6_000.0);
172        assert_eq!(detector.feed(&again), ['5', '5']);
173    }
174
175    #[test]
176    fn speech_like_audio_is_not_a_digit() {
177        // A voiced sound: a 140 Hz fundamental with falling harmonics that
178        // pass through the DTMF bands, plus noise.
179        let mut seed = 7u32;
180        let audio: Vec<i16> = (0..8_000 * 2)
181            .map(|n| {
182                let t = n as f64 / 8_000.0;
183                let voiced: f64 = (1..=25)
184                    .map(|h| {
185                        (4_000.0 / h as f64)
186                            * (2.0 * std::f64::consts::PI * 140.0 * h as f64 * t).sin()
187                    })
188                    .sum();
189                seed = seed.wrapping_mul(1_103_515_245).wrapping_add(12_345);
190                let noise = f64::from((seed >> 16) as i16 >> 4);
191                (voiced + noise) as i16
192            })
193            .collect();
194        let mut detector = DtmfDetector::new(8_000);
195        assert!(detector.feed(&audio).is_empty());
196    }
197
198    #[test]
199    fn quiet_or_single_tones_are_ignored() {
200        let mut detector = DtmfDetector::new(8_000);
201        assert!(detector.feed(&keypresses("7", 100, 0, 200.0)).is_empty());
202        // A lone 1 kHz tone (a dial tone or a beep) is not a keypress.
203        let beep: Vec<i16> = (0..1_600)
204            .map(|n| {
205                (8_000.0 * (2.0 * std::f64::consts::PI * 1_000.0 * n as f64 / 8_000.0).sin()) as i16
206            })
207            .collect();
208        assert!(detector.feed(&beep).is_empty());
209    }
210}