Skip to main content

wavekat_sip/rtp/
dtmf.rs

1//! RFC 4733 DTMF (`telephone-event`) packet construction and transmission.
2//!
3//! A DTMF "press" rides RTP as a sequence of small (4-byte payload)
4//! event packets. The first three carry the marker bit and a short
5//! initial duration; continuation packets follow every 20 ms with a
6//! growing duration field; three end packets with the `E` bit set
7//! close out the burst. The three-fold redundancy at the start and end
8//! protects against single-packet loss; without it, a dropped marker
9//! could silently swallow the digit, and a dropped end could leave it
10//! "stuck on" at the receiver.
11//!
12//! ## Why a separate SSRC
13//!
14//! RFC 4733 §2.6.2 explicitly allows telephone-event to ride either
15//! the audio SSRC (interleaved) or its own SSRC (a separate RTP
16//! stream). We pick the latter: it lets DTMF be sent without
17//! coordinating the sequence-number / timestamp counter with the
18//! audio send loop, keeps both code paths simple, and is what the
19//! receiver demuxes by payload type anyway. Pick a fresh random SSRC
20//! per call and keep it stable for that call's lifetime.
21//!
22//! ## Example
23//!
24//! ```no_run
25//! use std::net::SocketAddr;
26//! use std::sync::Arc;
27//! use tokio::net::UdpSocket;
28//! use wavekat_sip::rtp::dtmf::{send_dtmf_burst, DtmfBurstConfig, DtmfDigit};
29//!
30//! # async fn ex(socket: Arc<UdpSocket>, remote: SocketAddr) -> std::io::Result<()> {
31//! let mut seq = 1_000u16;
32//! let mut ts = 0_u32;
33//! let cfg = DtmfBurstConfig {
34//!     payload_type: 101,
35//!     ssrc: 0xDEAD_BEEF,
36//!     initial_seq: seq,
37//!     initial_timestamp: ts,
38//!     hold_duration_ms: 160,
39//!     volume_dbm0: 10,
40//! };
41//! let (next_seq, next_ts) =
42//!     send_dtmf_burst(socket, remote, cfg, DtmfDigit::D5).await?;
43//! seq = next_seq;
44//! ts = next_ts;
45//! # Ok(())
46//! # }
47//! ```
48//!
49//! [`send_dtmf_burst`] is `async` and `await`s ~20 ms between
50//! continuation packets, so a press of `hold_duration_ms = 160`
51//! takes roughly 160 ms wall-clock to complete.
52
53use std::net::SocketAddr;
54use std::sync::Arc;
55
56use tokio::net::UdpSocket;
57use tokio::time::{sleep, Duration};
58use tracing::debug;
59
60/// Default volume (dBm0) advertised in each event packet.
61///
62/// 10 dBm0 is the recommended fall-back per RFC 4733 §2.5.2.3 and the
63/// value every major softphone defaults to. The valid range is 0-63
64/// (each unit is one dB below the reference; higher = quieter).
65pub const DEFAULT_VOLUME_DBM0: u8 = 10;
66
67/// 20 ms at an 8 kHz clock = 160 sample ticks. The RTP timestamp clock
68/// for `telephone-event/8000` is 8 kHz regardless of the audio codec.
69const TICKS_PER_PACKET: u16 = 160;
70
71/// Inter-packet gap for continuation packets.
72const PACKET_INTERVAL: Duration = Duration::from_millis(20);
73
74/// Recommended number of duplicate copies for the first and last
75/// packets of a burst (RFC 4733 §2.5.1.4 — redundancy against single
76/// packet loss).
77const REDUNDANCY: u8 = 3;
78
79/// A single DTMF digit per RFC 4733 §2.5.2.1 event codes 0-15.
80///
81/// `D0`-`D9`, `Star`, `Pound`, `A`-`D`. The letter variants are vestigial
82/// (US AUTOVON keypad) and almost never appear in practice; consumers
83/// should typically only expose the digits and `* #` in their UI.
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
85pub enum DtmfDigit {
86    D0,
87    D1,
88    D2,
89    D3,
90    D4,
91    D5,
92    D6,
93    D7,
94    D8,
95    D9,
96    Star,
97    Pound,
98    A,
99    B,
100    C,
101    D,
102}
103
104impl DtmfDigit {
105    /// Parse a digit from one of `0`-`9`, `*`, `#`, or `A`-`D`
106    /// (case-insensitive for the letters). Returns `None` for anything
107    /// else, including whitespace.
108    pub fn from_char(c: char) -> Option<Self> {
109        Some(match c {
110            '0' => Self::D0,
111            '1' => Self::D1,
112            '2' => Self::D2,
113            '3' => Self::D3,
114            '4' => Self::D4,
115            '5' => Self::D5,
116            '6' => Self::D6,
117            '7' => Self::D7,
118            '8' => Self::D8,
119            '9' => Self::D9,
120            '*' => Self::Star,
121            '#' => Self::Pound,
122            'a' | 'A' => Self::A,
123            'b' | 'B' => Self::B,
124            'c' | 'C' => Self::C,
125            'd' | 'D' => Self::D,
126            _ => return None,
127        })
128    }
129
130    /// The canonical character for this digit, e.g. `5` for `D5`,
131    /// `*` for `Star`, `A` (uppercase) for `A`.
132    pub fn as_char(self) -> char {
133        match self {
134            Self::D0 => '0',
135            Self::D1 => '1',
136            Self::D2 => '2',
137            Self::D3 => '3',
138            Self::D4 => '4',
139            Self::D5 => '5',
140            Self::D6 => '6',
141            Self::D7 => '7',
142            Self::D8 => '8',
143            Self::D9 => '9',
144            Self::Star => '*',
145            Self::Pound => '#',
146            Self::A => 'A',
147            Self::B => 'B',
148            Self::C => 'C',
149            Self::D => 'D',
150        }
151    }
152
153    /// Map an RFC 4733 §2.5.2.1 event code back to a digit — the
154    /// inverse of [`DtmfDigit::event_code`]. Returns `None` for codes
155    /// ≥ 16 (flash-hook and the other non-DTMF telephone events).
156    pub fn from_event_code(code: u8) -> Option<Self> {
157        Some(match code {
158            0 => Self::D0,
159            1 => Self::D1,
160            2 => Self::D2,
161            3 => Self::D3,
162            4 => Self::D4,
163            5 => Self::D5,
164            6 => Self::D6,
165            7 => Self::D7,
166            8 => Self::D8,
167            9 => Self::D9,
168            10 => Self::Star,
169            11 => Self::Pound,
170            12 => Self::A,
171            13 => Self::B,
172            14 => Self::C,
173            15 => Self::D,
174            _ => return None,
175        })
176    }
177
178    /// The RFC 4733 §2.5.2.1 event code (0-15) for this digit.
179    pub fn event_code(self) -> u8 {
180        match self {
181            Self::D0 => 0,
182            Self::D1 => 1,
183            Self::D2 => 2,
184            Self::D3 => 3,
185            Self::D4 => 4,
186            Self::D5 => 5,
187            Self::D6 => 6,
188            Self::D7 => 7,
189            Self::D8 => 8,
190            Self::D9 => 9,
191            Self::Star => 10,
192            Self::Pound => 11,
193            Self::A => 12,
194            Self::B => 13,
195            Self::C => 14,
196            Self::D => 15,
197        }
198    }
199}
200
201/// Build the 4-byte RFC 4733 event payload.
202///
203/// Layout (RFC 4733 §2.5.2):
204///
205/// ```text
206///  0                   1                   2                   3
207///  0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
208/// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
209/// |     event     |E|R| volume    |          duration             |
210/// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
211/// ```
212///
213/// - `event`: digit code 0-15 (see [`DtmfDigit::event_code`]).
214/// - `E`: end-of-event flag (set on the last packet of a burst).
215/// - `R`: reserved (always 0).
216/// - `volume`: power in dBm0 (0-63, lower magnitude = louder; see
217///   [`DEFAULT_VOLUME_DBM0`]). Clamped to 6 bits.
218/// - `duration`: how long the event has been going so far, in RTP
219///   timestamp ticks (8 kHz). Grows across continuation packets.
220pub fn build_event_payload(event: u8, end: bool, volume: u8, duration_ticks: u16) -> [u8; 4] {
221    let mut out = [0u8; 4];
222    out[0] = event;
223    // Bit 7 = E (end), bit 6 = R (reserved/0), bits 0-5 = volume.
224    let end_bit = if end { 0x80 } else { 0x00 };
225    out[1] = end_bit | (volume & 0x3F);
226    let dur = duration_ticks.to_be_bytes();
227    out[2] = dur[0];
228    out[3] = dur[1];
229    out
230}
231
232/// Settings for a single DTMF burst.
233///
234/// Each press of one digit is one burst. For multi-digit dialing,
235/// pace the calls to [`send_dtmf_burst`] yourself — typically with an
236/// inter-digit gap of ≥50 ms so the receiver can demarcate them.
237#[derive(Debug, Clone, Copy)]
238pub struct DtmfBurstConfig {
239    /// Negotiated `telephone-event` payload type — typically 101. Get
240    /// this from [`crate::RemoteMedia::dtmf_payload_type`]; if it's
241    /// `None`, the remote didn't agree to RFC 4733 and DTMF must be
242    /// sent via SIP INFO instead.
243    pub payload_type: u8,
244    /// SSRC for the DTMF stream. Pick a fresh random value per call
245    /// (distinct from the audio stream's SSRC, per the module docs).
246    pub ssrc: u32,
247    /// RTP sequence number for the first packet of this burst. The
248    /// returned `(next_seq, _)` tuple is the seq to pass on the next
249    /// burst — keep the counter monotonic for the lifetime of the
250    /// SSRC, per RFC 3550.
251    pub initial_seq: u16,
252    /// RTP timestamp for the *start* of the event. All packets in
253    /// this burst share this value (RFC 4733 §2.5.1.2 — the start
254    /// time of the event being marked, not the time of the packet).
255    /// Pass the previous burst's returned `next_ts` for back-to-back
256    /// digits — that step keeps the timeline monotonic without
257    /// running a clock between presses.
258    pub initial_timestamp: u32,
259    /// How long to hold the digit, in milliseconds. Typical: 100-200.
260    /// The actual on-wire duration field uses 8 kHz ticks; this is
261    /// rounded to the nearest whole packet (`PACKET_INTERVAL` = 20 ms).
262    pub hold_duration_ms: u32,
263    /// Volume in dBm0 (0-63). See [`DEFAULT_VOLUME_DBM0`].
264    pub volume_dbm0: u8,
265}
266
267/// Build the full RTP packet (12-byte header + 4-byte event payload).
268///
269/// Exposed for tests and for consumers that want to send DTMF via a
270/// transport other than [`send_dtmf_burst`] (for example, batching
271/// packets onto a shared send loop).
272pub fn build_rtp_dtmf_packet(
273    payload_type: u8,
274    marker: bool,
275    seq: u16,
276    timestamp: u32,
277    ssrc: u32,
278    event_payload: [u8; 4],
279) -> [u8; 16] {
280    let mut pkt = [0u8; 16];
281    // V=2, no padding, no extension, CSRC count = 0.
282    pkt[0] = 0x80;
283    // Marker bit + payload type.
284    let marker_bit = if marker { 0x80 } else { 0x00 };
285    pkt[1] = marker_bit | (payload_type & 0x7F);
286    pkt[2..4].copy_from_slice(&seq.to_be_bytes());
287    pkt[4..8].copy_from_slice(&timestamp.to_be_bytes());
288    pkt[8..12].copy_from_slice(&ssrc.to_be_bytes());
289    pkt[12..16].copy_from_slice(&event_payload);
290    pkt
291}
292
293/// Send one digit as a burst of RFC 4733 event packets on the given
294/// socket, addressed to `remote`. Returns `(next_seq, next_timestamp)`
295/// — the values to thread into the next burst on the same SSRC.
296///
297/// `next_timestamp` is `initial_timestamp + final_duration_ticks`, so
298/// chaining bursts produces a monotonically increasing RTP timeline
299/// even when there's no audio on the same SSRC to fill the gaps.
300///
301/// Network policy: this function `await`s a UDP send per packet plus
302/// `sleep`s `PACKET_INTERVAL` between continuation packets. A digit
303/// with `hold_duration_ms = 160` therefore takes ≈ 160 ms wall-clock
304/// to finish. Cancel by dropping the returned future or by aborting
305/// the surrounding task — partial bursts are valid RTP (the receiver
306/// will see the missing end as packet loss and stop the tone).
307pub async fn send_dtmf_burst(
308    socket: Arc<UdpSocket>,
309    remote: SocketAddr,
310    config: DtmfBurstConfig,
311    digit: DtmfDigit,
312) -> Result<(u16, u32), std::io::Error> {
313    let event = digit.event_code();
314    let volume = config.volume_dbm0.min(0x3F);
315
316    debug!(
317        "DTMF burst '{}' → {remote} (PT={}, SSRC=0x{:08X}, hold={}ms)",
318        digit.as_char(),
319        config.payload_type,
320        config.ssrc,
321        config.hold_duration_ms,
322    );
323
324    // Total packets in the press: at least the 3 initial + 3 end.
325    // Continuation packets fill in the middle every 20 ms.
326    let total_packets = config.hold_duration_ms.div_ceil(20).max(1) as u16;
327    let total_duration_ticks = total_packets.saturating_mul(TICKS_PER_PACKET);
328
329    let mut seq = config.initial_seq;
330    let mut current_duration = TICKS_PER_PACKET;
331
332    // Marker bit + first three copies — `E=0`, duration = one tick.
333    // All three share the marker'd timestamp; the receiver de-dupes by
334    // seq/duration but treats the first arrival as the start of the
335    // event.
336    for i in 0..REDUNDANCY {
337        let payload = build_event_payload(event, false, volume, current_duration);
338        let marker = i == 0;
339        let pkt = build_rtp_dtmf_packet(
340            config.payload_type,
341            marker,
342            seq,
343            config.initial_timestamp,
344            config.ssrc,
345            payload,
346        );
347        socket.send_to(&pkt, remote).await?;
348        seq = seq.wrapping_add(1);
349    }
350
351    // Continuation packets — one per 20 ms tick, growing duration.
352    // `total_packets - 1` because the initial trio already covered the
353    // first tick.
354    for _ in 1..total_packets {
355        sleep(PACKET_INTERVAL).await;
356        current_duration = current_duration.saturating_add(TICKS_PER_PACKET);
357        let payload = build_event_payload(event, false, volume, current_duration);
358        let pkt = build_rtp_dtmf_packet(
359            config.payload_type,
360            false,
361            seq,
362            config.initial_timestamp,
363            config.ssrc,
364            payload,
365        );
366        socket.send_to(&pkt, remote).await?;
367        seq = seq.wrapping_add(1);
368    }
369
370    // Three end packets — `E=1`, final duration. Sent back-to-back at
371    // this tick boundary; no sleep between them.
372    for _ in 0..REDUNDANCY {
373        let payload = build_event_payload(event, true, volume, current_duration);
374        let pkt = build_rtp_dtmf_packet(
375            config.payload_type,
376            false,
377            seq,
378            config.initial_timestamp,
379            config.ssrc,
380            payload,
381        );
382        socket.send_to(&pkt, remote).await?;
383        seq = seq.wrapping_add(1);
384    }
385
386    let next_timestamp = config
387        .initial_timestamp
388        .wrapping_add(total_duration_ticks as u32);
389    Ok((seq, next_timestamp))
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395
396    #[test]
397    fn digit_from_char_round_trips() {
398        for d in [
399            DtmfDigit::D0,
400            DtmfDigit::D1,
401            DtmfDigit::D2,
402            DtmfDigit::D3,
403            DtmfDigit::D4,
404            DtmfDigit::D5,
405            DtmfDigit::D6,
406            DtmfDigit::D7,
407            DtmfDigit::D8,
408            DtmfDigit::D9,
409            DtmfDigit::Star,
410            DtmfDigit::Pound,
411            DtmfDigit::A,
412            DtmfDigit::B,
413            DtmfDigit::C,
414            DtmfDigit::D,
415        ] {
416            assert_eq!(DtmfDigit::from_char(d.as_char()), Some(d), "digit {d:?}");
417        }
418    }
419
420    #[test]
421    fn digit_from_event_code_round_trips() {
422        for code in 0u8..16 {
423            let d = DtmfDigit::from_event_code(code).expect("codes 0-15 are digits");
424            assert_eq!(d.event_code(), code, "code {code}");
425        }
426    }
427
428    #[test]
429    fn digit_from_event_code_rejects_non_dtmf_events() {
430        // 16 = flash-hook, and everything above is fax/modem signaling
431        // per the RFC 4733 event registry — not keypad digits.
432        for code in [16u8, 17, 63, 255] {
433            assert_eq!(DtmfDigit::from_event_code(code), None, "code {code}");
434        }
435    }
436
437    #[test]
438    fn digit_event_codes_match_rfc_4733() {
439        // Spot-check the spec assignments — these are wire-visible and a
440        // typo would scramble every IVR's "you pressed 7" response.
441        assert_eq!(DtmfDigit::D0.event_code(), 0);
442        assert_eq!(DtmfDigit::D9.event_code(), 9);
443        assert_eq!(DtmfDigit::Star.event_code(), 10);
444        assert_eq!(DtmfDigit::Pound.event_code(), 11);
445        assert_eq!(DtmfDigit::A.event_code(), 12);
446        assert_eq!(DtmfDigit::D.event_code(), 15);
447    }
448
449    #[test]
450    fn digit_from_char_rejects_non_dtmf() {
451        for c in [' ', 'e', 'E', '+', '-', '\n'] {
452            assert_eq!(DtmfDigit::from_char(c), None, "should reject {c:?}");
453        }
454    }
455
456    #[test]
457    fn digit_from_char_accepts_letters_case_insensitive() {
458        assert_eq!(DtmfDigit::from_char('a'), Some(DtmfDigit::A));
459        assert_eq!(DtmfDigit::from_char('A'), Some(DtmfDigit::A));
460        assert_eq!(DtmfDigit::from_char('d'), Some(DtmfDigit::D));
461        assert_eq!(DtmfDigit::from_char('D'), Some(DtmfDigit::D));
462    }
463
464    #[test]
465    fn event_payload_byte_layout() {
466        // event=5, E=0, volume=10, duration=160 — typical first packet
467        // of pressing "5".
468        let p = build_event_payload(5, false, 10, 160);
469        assert_eq!(p[0], 5);
470        // 0x0A = 10; E and R both 0.
471        assert_eq!(p[1], 0x0A);
472        assert_eq!(p[2..4], 160u16.to_be_bytes());
473
474        // E bit set on the last packet of a burst.
475        let end = build_event_payload(5, true, 10, 1280);
476        // E bit (0x80) plus volume 10.
477        assert_eq!(end[1], 0x8A);
478        assert_eq!(end[2..4], 1280u16.to_be_bytes());
479    }
480
481    #[test]
482    fn event_payload_clamps_volume_to_6_bits() {
483        // A consumer passing volume=100 (out of the 0-63 range) must not
484        // bleed into the E or R bits — the volume field is 6 bits.
485        let p = build_event_payload(5, false, 0xFF, 160);
486        // Top two bits must be zero (E=0, R=0); bottom six = volume.
487        assert_eq!(p[1] & 0xC0, 0x00);
488        assert_eq!(p[1] & 0x3F, 0x3F);
489    }
490
491    #[test]
492    fn rtp_dtmf_packet_header_shape() {
493        // Single packet build: V=2, marker set, PT=101, seq/ts/ssrc as
494        // given, event payload appended verbatim.
495        let event = build_event_payload(5, false, 10, 160);
496        let pkt = build_rtp_dtmf_packet(101, true, 1000, 12345, 0xCAFE_BABE, event);
497
498        assert_eq!(pkt[0], 0x80); // V=2, P=0, X=0, CC=0
499        assert_eq!(pkt[1], 0x80 | 101); // marker | PT 101
500        assert_eq!(&pkt[2..4], &1000u16.to_be_bytes());
501        assert_eq!(&pkt[4..8], &12345u32.to_be_bytes());
502        assert_eq!(&pkt[8..12], &0xCAFE_BABEu32.to_be_bytes());
503        assert_eq!(&pkt[12..16], &event);
504    }
505
506    /// Bind a pair of UDP sockets on loopback. Returns (sender, receiver).
507    async fn loopback_pair() -> (Arc<UdpSocket>, UdpSocket) {
508        let a = UdpSocket::bind("127.0.0.1:0").await.unwrap();
509        let b = UdpSocket::bind("127.0.0.1:0").await.unwrap();
510        (Arc::new(a), b)
511    }
512
513    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
514    async fn burst_packet_count_and_durations() {
515        // A 100 ms press at 20 ms per tick is 5 ticks. With 3 initial
516        // duplicates and 3 end duplicates we expect:
517        //   3 (initial, dur=160)
518        // + 4 (continuation, dur=320, 480, 640, 800)
519        // + 3 (end, dur=800, E=1)
520        // = 10 packets. The continuation count is `total_packets - 1`
521        // because the initial trio already covered the first tick.
522        let (sender, receiver) = loopback_pair().await;
523        let remote = receiver.local_addr().unwrap();
524
525        let cfg = DtmfBurstConfig {
526            payload_type: 101,
527            ssrc: 0xDEAD_BEEF,
528            initial_seq: 100,
529            initial_timestamp: 5000,
530            hold_duration_ms: 100,
531            volume_dbm0: 10,
532        };
533
534        let handle = tokio::spawn(send_dtmf_burst(sender, remote, cfg, DtmfDigit::D5));
535
536        let mut buf = [0u8; 64];
537        let mut packets: Vec<[u8; 4]> = Vec::new();
538        let mut markers: Vec<bool> = Vec::new();
539        let mut seqs: Vec<u16> = Vec::new();
540        for _ in 0..10 {
541            let (n, _) =
542                tokio::time::timeout(Duration::from_millis(500), receiver.recv_from(&mut buf))
543                    .await
544                    .expect("packet arrived in time")
545                    .expect("recv ok");
546            assert_eq!(n, 16, "DTMF packets are 12 + 4 bytes");
547            markers.push(buf[1] & 0x80 != 0);
548            seqs.push(u16::from_be_bytes([buf[2], buf[3]]));
549            let mut payload = [0u8; 4];
550            payload.copy_from_slice(&buf[12..16]);
551            packets.push(payload);
552        }
553
554        let (next_seq, next_ts) = handle.await.unwrap().unwrap();
555        assert_eq!(next_seq, 100 + 10);
556        // 5 ticks × 160 = 800
557        assert_eq!(next_ts, 5000 + 800);
558
559        // Sequence numbers strictly increasing by 1 from initial_seq.
560        for (i, s) in seqs.iter().enumerate() {
561            assert_eq!(*s, 100 + i as u16, "packet {i}");
562        }
563
564        // First packet has marker; nothing else does. (Per RFC 4733
565        // §2.5.1.1 the marker rides only on the first packet of the
566        // talkspurt.)
567        assert!(markers[0], "marker on first packet");
568        for (i, m) in markers.iter().enumerate().skip(1) {
569            assert!(!m, "no marker on packet {i}");
570        }
571
572        // Each payload's first byte = event code 5.
573        for p in &packets {
574            assert_eq!(p[0], 5);
575        }
576
577        // Initial three: E=0, dur=160.
578        for p in &packets[0..3] {
579            assert_eq!(p[1] & 0x80, 0, "E bit clear on initial");
580            assert_eq!(u16::from_be_bytes([p[2], p[3]]), 160);
581        }
582
583        // Continuation 4 packets: E=0, dur grows 320, 480, 640, 800.
584        let expected_durations = [320u16, 480, 640, 800];
585        for (p, &dur) in packets[3..7].iter().zip(expected_durations.iter()) {
586            assert_eq!(p[1] & 0x80, 0, "E bit clear on continuation");
587            assert_eq!(u16::from_be_bytes([p[2], p[3]]), dur);
588        }
589
590        // End three: E=1, dur=800 (the final tick).
591        for p in &packets[7..10] {
592            assert_eq!(p[1] & 0x80, 0x80, "E bit set on end packet");
593            assert_eq!(u16::from_be_bytes([p[2], p[3]]), 800);
594        }
595    }
596
597    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
598    async fn chained_bursts_keep_timestamp_monotonic() {
599        // Pressing "5" then "7" back-to-back must produce a continuous
600        // RTP timeline on the same SSRC, with the second burst's
601        // timestamps starting where the first left off.
602        let (sender, receiver) = loopback_pair().await;
603        let remote = receiver.local_addr().unwrap();
604
605        let cfg1 = DtmfBurstConfig {
606            payload_type: 101,
607            ssrc: 0xCAFE_F00D,
608            initial_seq: 0,
609            initial_timestamp: 0,
610            hold_duration_ms: 40, // 2 ticks → 3 init + 1 cont + 3 end = 7 packets
611            volume_dbm0: 10,
612        };
613
614        let receiver_task = tokio::spawn(async move {
615            let mut buf = [0u8; 64];
616            let mut count = 0;
617            while count < 14 {
618                let (_n, _) =
619                    tokio::time::timeout(Duration::from_millis(500), receiver.recv_from(&mut buf))
620                        .await
621                        .unwrap()
622                        .unwrap();
623                count += 1;
624            }
625        });
626
627        let (s1, t1) = send_dtmf_burst(sender.clone(), remote, cfg1, DtmfDigit::D5)
628            .await
629            .unwrap();
630        // 7 packets, 2 ticks × 160 = 320.
631        assert_eq!(s1, 7);
632        assert_eq!(t1, 320);
633
634        let cfg2 = DtmfBurstConfig {
635            initial_seq: s1,
636            initial_timestamp: t1,
637            ..cfg1
638        };
639        let (s2, t2) = send_dtmf_burst(sender, remote, cfg2, DtmfDigit::D7)
640            .await
641            .unwrap();
642        assert_eq!(s2, 14);
643        assert_eq!(t2, 640);
644
645        receiver_task.await.unwrap();
646    }
647}