gemini_adk_fluent_rs/telephony/
twilio.rs

1//! Twilio Media Streams — a phone call as two channels of JSON text frames.
2//!
3//! Twilio's [Media Streams](https://www.twilio.com/docs/voice/media-streams)
4//! forks a live call's audio over a WebSocket: μ-law 8 kHz frames arrive as
5//! base64 inside JSON text messages, and JSON text messages you send play
6//! back to the caller. This module speaks that protocol and adapts it onto
7//! [`voice::pump`](crate::voice::pump) — so a governed Live session answers
8//! the phone with the same barge-in guarantees as a local microphone:
9//!
10//! - inbound `media` frames are μ-law-decoded to PCM16 @ 8 kHz and fed to the
11//!   pump, which resamples to the session's 16 kHz input;
12//! - session audio comes back from the pump at 8 kHz, is μ-law-encoded, and
13//!   sent as outbound `media` frames;
14//! - an interruption ([`Playback::Flush`](crate::voice::Playback)) becomes a
15//!   Twilio `clear` message, dropping every frame Twilio has buffered — the
16//!   telephone form of "stop talking the instant the caller does";
17//! - DTMF digits land in session state under [`KEY_DTMF`] / [`KEY_DTMF_HISTORY`],
18//!   where flow guards and watchers read them like any other fact.
19//!
20//! The bridge owns no socket. [`TwilioCall::attach`] returns a sender for the
21//! text frames Twilio delivers and a receiver of the text frames to forward
22//! back — wire them to any WebSocket server (see `examples/telephony` for an
23//! axum one). Raw SIP (no Twilio in the path) is out of scope here; that is
24//! the roadmap's `rsipstack` integration.
25
26use std::collections::HashMap;
27
28use base64::Engine as _;
29use serde::{Deserialize, Serialize};
30use tokio::sync::{mpsc, watch};
31use tokio::task::JoinHandle;
32
33use gemini_adk_rs::live::LiveHandle;
34
35use super::g711;
36use crate::voice::{Playback, VoicePump, pump};
37
38/// The sample rate of every Twilio Media Stream, both directions.
39pub const TWILIO_HZ: u32 = 8_000;
40
41// The state-key vocabulary is shared across every connector — see
42// [`super::bridge`]. Re-exported here so existing imports keep working.
43pub use super::bridge::{KEY_CALL_SID, KEY_DTMF, KEY_DTMF_HISTORY, KEY_STREAM_SID};
44
45// ── Inbound protocol (Twilio → us) ───────────────────────────────────────────
46
47/// Metadata delivered by Twilio's `start` frame when the stream opens.
48#[derive(Debug, Clone, Deserialize, Default, PartialEq)]
49#[serde(rename_all = "camelCase", default)]
50pub struct StartMeta {
51    /// The stream SID — required in every frame sent back to Twilio.
52    pub stream_sid: String,
53    /// The SID of the underlying voice call.
54    pub call_sid: String,
55    /// The Twilio account SID.
56    pub account_sid: String,
57    /// Which tracks are being forked (normally `["inbound"]`).
58    pub tracks: Vec<String>,
59    /// Audio format of the stream (normally `audio/x-mulaw` @ 8000 Hz mono).
60    pub media_format: MediaFormat,
61    /// `<Parameter>` values set on the `<Stream>` TwiML noun.
62    pub custom_parameters: HashMap<String, String>,
63}
64
65/// The audio encoding Twilio declares in the `start` frame.
66#[derive(Debug, Clone, Deserialize, Default, PartialEq)]
67#[serde(rename_all = "camelCase", default)]
68pub struct MediaFormat {
69    /// MIME-style encoding name, e.g. `audio/x-mulaw`.
70    pub encoding: String,
71    /// Sample rate in Hz (8000 for telephone audio).
72    pub sample_rate: u32,
73    /// Channel count (1 for a single forked track).
74    pub channels: u32,
75}
76
77#[derive(Debug, Clone, Deserialize)]
78#[serde(rename_all = "camelCase")]
79struct MediaPayload {
80    #[serde(default)]
81    track: String,
82    payload: String,
83}
84
85#[derive(Debug, Clone, Deserialize)]
86struct DtmfPayload {
87    digit: String,
88}
89
90#[derive(Debug, Clone, Deserialize)]
91struct MarkPayload {
92    name: String,
93}
94
95#[derive(Debug, Clone, Deserialize)]
96#[serde(tag = "event", rename_all = "lowercase")]
97enum RawInbound {
98    Connected {},
99    Start { start: StartMeta },
100    Media { media: MediaPayload },
101    Dtmf { dtmf: DtmfPayload },
102    Mark { mark: MarkPayload },
103    Stop {},
104}
105
106/// One decoded frame from Twilio, ready for application handling.
107#[derive(Debug, Clone, PartialEq)]
108pub enum Inbound {
109    /// The WebSocket handshake completed (`connected`). No stream SID yet.
110    Connected,
111    /// The stream opened; carries SIDs, tracks, format, and TwiML parameters.
112    Started(StartMeta),
113    /// One chunk of caller audio, μ-law-decoded to mono PCM16 @ 8 kHz.
114    Audio(Vec<i16>),
115    /// The caller pressed a DTMF key.
116    Dtmf(char),
117    /// Twilio confirms playback reached a `mark` we sent.
118    Mark(String),
119    /// The stream ended (call hung up or `<Stream>` stopped).
120    Stopped,
121    /// An event this version does not model (forward-compatible skip).
122    Ignored,
123}
124
125/// Parse one Twilio Media Streams text frame.
126///
127/// Unknown event types parse to [`Inbound::Ignored`] so a new Twilio event
128/// never breaks an existing bridge; malformed JSON is an error.
129pub fn parse_inbound(text: &str) -> Result<Inbound, TwilioError> {
130    let raw: RawInbound = match serde_json::from_str(text) {
131        Ok(raw) => raw,
132        Err(_) => {
133            // Distinguish "not JSON" from "JSON with an unknown event tag".
134            let value: serde_json::Value =
135                serde_json::from_str(text).map_err(TwilioError::Malformed)?;
136            return if value.get("event").is_some() {
137                Ok(Inbound::Ignored)
138            } else {
139                Err(TwilioError::NotAFrame)
140            };
141        }
142    };
143    Ok(match raw {
144        RawInbound::Connected {} => Inbound::Connected,
145        RawInbound::Start { start } => Inbound::Started(start),
146        RawInbound::Media { media } => {
147            // Only the caller's track feeds the session; if outbound audio is
148            // also forked (`tracks: ["both"]`), skip our own voice.
149            if !media.track.is_empty() && media.track != "inbound" {
150                return Ok(Inbound::Ignored);
151            }
152            let mulaw = base64::engine::general_purpose::STANDARD
153                .decode(media.payload.as_bytes())
154                .map_err(TwilioError::BadPayload)?;
155            Inbound::Audio(g711::decode_ulaw(&mulaw))
156        }
157        RawInbound::Dtmf { dtmf } => match dtmf.digit.chars().next() {
158            Some(digit) => Inbound::Dtmf(digit),
159            None => Inbound::Ignored,
160        },
161        RawInbound::Mark { mark } => Inbound::Mark(mark.name),
162        RawInbound::Stop {} => Inbound::Stopped,
163    })
164}
165
166/// Errors from parsing Twilio frames.
167#[derive(Debug)]
168pub enum TwilioError {
169    /// The text was not valid JSON.
170    Malformed(serde_json::Error),
171    /// The JSON carried no `event` field — not a Media Streams frame.
172    NotAFrame,
173    /// A `media` frame's payload was not valid base64.
174    BadPayload(base64::DecodeError),
175}
176
177impl std::fmt::Display for TwilioError {
178    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
179        match self {
180            Self::Malformed(e) => write!(f, "malformed Twilio frame: {e}"),
181            Self::NotAFrame => write!(f, "JSON without an event field"),
182            Self::BadPayload(e) => write!(f, "invalid base64 media payload: {e}"),
183        }
184    }
185}
186
187impl std::error::Error for TwilioError {}
188
189// ── Outbound protocol (us → Twilio) ──────────────────────────────────────────
190
191#[derive(Serialize)]
192#[serde(rename_all = "camelCase")]
193struct OutMedia<'a> {
194    event: &'static str,
195    stream_sid: &'a str,
196    media: OutMediaPayload,
197}
198
199#[derive(Serialize)]
200struct OutMediaPayload {
201    payload: String,
202}
203
204#[derive(Serialize)]
205#[serde(rename_all = "camelCase")]
206struct OutClear<'a> {
207    event: &'static str,
208    stream_sid: &'a str,
209}
210
211#[derive(Serialize)]
212#[serde(rename_all = "camelCase")]
213struct OutMark<'a> {
214    event: &'static str,
215    stream_sid: &'a str,
216    mark: OutMarkPayload<'a>,
217}
218
219#[derive(Serialize)]
220struct OutMarkPayload<'a> {
221    name: &'a str,
222}
223
224/// Build an outbound `media` frame from mono PCM16 samples @ 8 kHz.
225pub fn media_frame(stream_sid: &str, samples: &[i16]) -> String {
226    let payload = base64::engine::general_purpose::STANDARD.encode(g711::encode_ulaw(samples));
227    serde_json::to_string(&OutMedia {
228        event: "media",
229        stream_sid,
230        media: OutMediaPayload { payload },
231    })
232    .expect("media frame serializes")
233}
234
235/// Build a `clear` frame — Twilio drops all buffered outbound audio.
236/// This is barge-in on the telephone: send it on [`Playback::Flush`].
237pub fn clear_frame(stream_sid: &str) -> String {
238    serde_json::to_string(&OutClear {
239        event: "clear",
240        stream_sid,
241    })
242    .expect("clear frame serializes")
243}
244
245/// Build a `mark` frame — Twilio echoes it back once playback reaches it.
246pub fn mark_frame(stream_sid: &str, name: &str) -> String {
247    serde_json::to_string(&OutMark {
248        event: "mark",
249        stream_sid,
250        mark: OutMarkPayload { name },
251    })
252    .expect("mark frame serializes")
253}
254
255// ── Call bridge ──────────────────────────────────────────────────────────────
256
257/// 20 ms of audio at Twilio's 8 kHz: the frame size the network expects.
258const FRAME_SAMPLES: usize = 160;
259const FRAME_DURATION: std::time::Duration = std::time::Duration::from_millis(20);
260/// How long to wait for more audio before padding a partial frame.
261const TAIL_WAIT: std::time::Duration = std::time::Duration::from_millis(40);
262
263/// State key set on barge-in: how many milliseconds of agent audio had been
264/// sent but not yet played when the caller interrupted. The caller never
265/// heard that part of the reply.
266pub const KEY_UNPLAYED_MS: &str = "telephony:unplayed_ms";
267
268/// Cuts agent audio into 20 ms frames and keeps the line's playout clock.
269#[derive(Debug, Default)]
270struct Framer {
271    /// A partial frame waiting for the rest of its 20 ms.
272    pending: Vec<i16>,
273    /// When the audio sent so far finishes playing on the line.
274    plays_until: Option<std::time::Instant>,
275}
276
277impl Framer {
278    fn is_idle(&self) -> bool {
279        self.pending.is_empty()
280    }
281
282    /// Queue audio; returns the whole frames now ready to send.
283    fn push(&mut self, samples: &[i16], now: std::time::Instant) -> Vec<Vec<i16>> {
284        self.pending.extend_from_slice(samples);
285        let mut frames = Vec::new();
286        while self.pending.len() >= FRAME_SAMPLES {
287            frames.push(self.pending.drain(..FRAME_SAMPLES).collect());
288            let from = self.plays_until.map_or(now, |t| t.max(now));
289            self.plays_until = Some(from + FRAME_DURATION);
290        }
291        frames
292    }
293
294    /// Pad the partial frame with silence, so the next `push` sends it.
295    fn pad_tail(&mut self) {
296        if !self.pending.is_empty() {
297            self.pending.resize(FRAME_SAMPLES, 0);
298        }
299    }
300
301    /// Barge-in: drop what is queued; returns how much sent audio had not
302    /// played yet.
303    fn flush(&mut self, now: std::time::Instant) -> std::time::Duration {
304        self.pending.clear();
305        let unplayed = self.plays_until.map_or(std::time::Duration::ZERO, |t| {
306            t.saturating_duration_since(now)
307        });
308        self.plays_until = Some(now);
309        unplayed
310    }
311}
312
313/// Options for [`TwilioCall::attach_with`].
314#[derive(Clone, Default)]
315pub struct CallOptions {
316    /// Record the call in stereo (caller left, agent right), as heard.
317    pub recorder: Option<std::sync::Arc<super::recorder::CallRecorder>>,
318    /// Also detect keypresses in the audio. Twilio reports keypresses as
319    /// `dtmf` events, so this is off by default to avoid counting twice.
320    pub in_band_dtmf: bool,
321}
322
323/// A live phone call attached to a session — the telephone counterpart of
324/// `Talk::talk`.
325///
326/// [`attach`](TwilioCall::attach) wires the session's [`pump`] to a pair of
327/// text-frame channels speaking the Media Streams protocol. The caller owns
328/// the WebSocket: forward every text message Twilio delivers into
329/// [`from_twilio`](TwilioCall::from_twilio), and forward every message from
330/// [`to_twilio`](TwilioCall::to_twilio) back over the socket.
331///
332/// ```ignore
333/// // `ignore`: `ws` is the application's Twilio WebSocket (see examples/telephony).
334/// let session = Live::builder()
335///     .instruction("You are the front desk. Answer the call.")
336///     .greeting("Greet the caller.")
337///     .connect_from_env().await?;
338/// let mut call = TwilioCall::attach(&session);
339/// loop {
340///     tokio::select! {
341///         Some(msg) = ws.recv() => call.from_twilio.send(msg.into_text()?).await?,
342///         Some(out) = call.to_twilio.recv() => ws.send(Message::Text(out)).await?,
343///         else => break,
344///     }
345/// }
346/// ```
347pub struct TwilioCall {
348    /// Feed Twilio's inbound WebSocket text frames here.
349    pub from_twilio: mpsc::Sender<String>,
350    /// Text frames to send back to Twilio over the WebSocket.
351    pub to_twilio: mpsc::Receiver<String>,
352    pump: VoicePump,
353    inbound_task: JoinHandle<()>,
354    outbound_task: JoinHandle<()>,
355}
356
357impl TwilioCall {
358    /// Attach a Twilio Media Stream to a connected session, with the
359    /// default [`CallOptions`].
360    ///
361    /// Audio starts flowing once Twilio's `start` frame arrives (outbound
362    /// frames need its stream SID; audio generated before it is dropped —
363    /// there is no call to play it into yet). Stream metadata and DTMF
364    /// digits are written into session state under the `telephony:` keys.
365    pub fn attach(handle: &LiveHandle) -> TwilioCall {
366        Self::attach_with(handle, CallOptions::default())
367    }
368
369    /// Attach a Twilio Media Stream to a connected session.
370    ///
371    /// Beyond [`attach`](Self::attach):
372    /// - the caller's audio passes through a
373    ///   [`KeypadGuard`](super::bridge::KeypadGuard), so keypad tones never
374    ///   reach the model and a masked keypad silences the caller;
375    /// - the agent's audio goes out in 20 ms frames, as the network expects,
376    ///   with the last partial frame padded once the model pauses;
377    /// - on barge-in, [`KEY_UNPLAYED_MS`] records how much queued agent
378    ///   audio the caller never heard;
379    /// - with [`CallOptions::recorder`], the call is recorded in stereo as
380    ///   heard.
381    pub fn attach_with(handle: &LiveHandle, options: CallOptions) -> TwilioCall {
382        let (from_tx, mut from_rx) = mpsc::channel::<String>(64);
383        let (to_tx, to_rx) = mpsc::channel::<String>(64);
384        let (mic_tx, mic_rx) = mpsc::channel::<Vec<i16>>(64);
385        let (speaker_tx, mut speaker_rx) = mpsc::channel::<Playback>(64);
386        let (sid_tx, sid_rx) = watch::channel::<Option<String>>(None);
387
388        let voice_pump = pump(handle, mic_rx, TWILIO_HZ, speaker_tx, TWILIO_HZ);
389
390        let state = handle.state().clone();
391        let mut guard = super::bridge::KeypadGuard::new(state.clone(), TWILIO_HZ)
392            .record_in_band(options.in_band_dtmf);
393        let inbound_recorder = options.recorder.clone();
394        let inbound_task = tokio::spawn(async move {
395            use crate::voice::InputAudioProcessor as _;
396            while let Some(text) = from_rx.recv().await {
397                match parse_inbound(&text) {
398                    Ok(Inbound::Audio(mut samples)) => {
399                        guard.process_frame(&mut samples);
400                        if let Some(recorder) = &inbound_recorder {
401                            recorder.caller(&samples);
402                        }
403                        if mic_tx.send(samples).await.is_err() {
404                            break;
405                        }
406                    }
407                    Ok(Inbound::Started(meta)) => {
408                        let _ = state.set(KEY_CALL_SID, meta.call_sid.clone());
409                        let _ = state.set(KEY_STREAM_SID, meta.stream_sid.clone());
410                        let _ = sid_tx.send(Some(meta.stream_sid));
411                    }
412                    Ok(Inbound::Dtmf(digit)) => super::bridge::record_dtmf(&state, digit),
413                    Ok(Inbound::Stopped) => break,
414                    Ok(Inbound::Connected | Inbound::Mark(_) | Inbound::Ignored) => {}
415                    Err(err) => tracing::warn!("dropping unparseable Twilio frame: {err}"),
416                }
417            }
418        });
419
420        let out_state = handle.state().clone();
421        let outbound_recorder = options.recorder;
422        let outbound_task = tokio::spawn(async move {
423            let mut framer = Framer::default();
424            loop {
425                let next = if framer.is_idle() {
426                    speaker_rx.recv().await
427                } else {
428                    match tokio::time::timeout(TAIL_WAIT, speaker_rx.recv()).await {
429                        Ok(next) => next,
430                        // The model paused mid-frame: pad and send the tail.
431                        Err(_) => {
432                            framer.pad_tail();
433                            Some(Playback::Chunk(Vec::new()))
434                        }
435                    }
436                };
437                let Some(playback) = next else { break };
438                // Frames are unsendable until `start` delivers the SID.
439                let Some(sid) = sid_rx.borrow().clone() else {
440                    continue;
441                };
442                let now = std::time::Instant::now();
443                let mut frames = Vec::new();
444                match playback {
445                    Playback::Chunk(samples) => {
446                        for frame in framer.push(&samples, now) {
447                            if let Some(recorder) = &outbound_recorder {
448                                recorder.agent(&frame);
449                            }
450                            frames.push(media_frame(&sid, &frame));
451                        }
452                    }
453                    Playback::Flush => {
454                        let unplayed = framer.flush(now);
455                        let _ = out_state.set(KEY_UNPLAYED_MS, unplayed.as_millis() as u64);
456                        if let Some(recorder) = &outbound_recorder {
457                            recorder.flush();
458                        }
459                        frames.push(clear_frame(&sid));
460                    }
461                }
462                for frame in frames {
463                    if to_tx.send(frame).await.is_err() {
464                        return;
465                    }
466                }
467            }
468        });
469
470        TwilioCall {
471            from_twilio: from_tx,
472            to_twilio: to_rx,
473            pump: voice_pump,
474            inbound_task,
475            outbound_task,
476        }
477    }
478
479    /// Wait until the call ends (stream stopped, session closed, or the
480    /// WebSocket side dropped the channels).
481    pub async fn join(self) {
482        let _ = self.inbound_task.await;
483        let _ = self.outbound_task.await;
484        self.pump.join().await;
485    }
486
487    /// Tear the bridge down immediately.
488    pub fn abort(&self) {
489        self.inbound_task.abort();
490        self.outbound_task.abort();
491        self.pump.abort();
492    }
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498
499    #[test]
500    fn agent_audio_goes_out_in_20ms_frames_on_a_playout_clock() {
501        let t0 = std::time::Instant::now();
502        let mut framer = Framer::default();
503        // 50 ms of audio in one burst: two whole frames now, 10 ms pending.
504        let frames = framer.push(&[1; 400], t0);
505        assert_eq!(frames.len(), 2);
506        assert!(frames.iter().all(|f| f.len() == FRAME_SAMPLES));
507        assert!(!framer.is_idle());
508        // The model pauses: the tail is padded to a full frame.
509        framer.pad_tail();
510        let tail = framer.push(&[], t0);
511        assert_eq!(tail.len(), 1);
512        assert_eq!(&tail[0][..80], &[1; 80]);
513        assert_eq!(&tail[0][80..], &[0; 80]);
514        // 60 ms were sent at t0; a barge-in 25 ms later cuts 35 ms.
515        let unplayed = framer.flush(t0 + std::time::Duration::from_millis(25));
516        assert_eq!(unplayed, std::time::Duration::from_millis(35));
517        assert!(framer.is_idle());
518    }
519
520    #[test]
521    fn parses_the_start_frame() {
522        let text = r#"{
523            "event": "start", "sequenceNumber": "1", "streamSid": "MZxyz",
524            "start": {
525                "accountSid": "ACabc", "streamSid": "MZxyz", "callSid": "CAdef",
526                "tracks": ["inbound"],
527                "mediaFormat": {"encoding": "audio/x-mulaw", "sampleRate": 8000, "channels": 1},
528                "customParameters": {"agent": "front-desk"}
529            }
530        }"#;
531        match parse_inbound(text).unwrap() {
532            Inbound::Started(meta) => {
533                assert_eq!(meta.stream_sid, "MZxyz");
534                assert_eq!(meta.call_sid, "CAdef");
535                assert_eq!(meta.media_format.sample_rate, 8000);
536                assert_eq!(meta.custom_parameters["agent"], "front-desk");
537            }
538            other => panic!("expected Started, got {other:?}"),
539        }
540    }
541
542    #[test]
543    fn media_frames_decode_to_pcm() {
544        // 0xFF is μ-law silence; four bytes → four zero samples.
545        let payload = base64::engine::general_purpose::STANDARD.encode([0xFFu8; 4]);
546        let text = format!(
547            r#"{{"event":"media","streamSid":"MZ1","media":{{"track":"inbound","chunk":"1","timestamp":"5","payload":"{payload}"}}}}"#
548        );
549        assert_eq!(parse_inbound(&text).unwrap(), Inbound::Audio(vec![0i16; 4]));
550    }
551
552    #[test]
553    fn outbound_track_media_is_skipped() {
554        let payload = base64::engine::general_purpose::STANDARD.encode([0xFFu8; 4]);
555        let text = format!(
556            r#"{{"event":"media","streamSid":"MZ1","media":{{"track":"outbound","payload":"{payload}"}}}}"#
557        );
558        assert_eq!(parse_inbound(&text).unwrap(), Inbound::Ignored);
559    }
560
561    #[test]
562    fn dtmf_marks_stop_and_unknown_events() {
563        assert_eq!(
564            parse_inbound(
565                r#"{"event":"dtmf","streamSid":"MZ1","dtmf":{"track":"inbound_track","digit":"7"}}"#
566            )
567            .unwrap(),
568            Inbound::Dtmf('7')
569        );
570        assert_eq!(
571            parse_inbound(r#"{"event":"mark","streamSid":"MZ1","mark":{"name":"m1"}}"#).unwrap(),
572            Inbound::Mark("m1".into())
573        );
574        assert_eq!(
575            parse_inbound(r#"{"event":"stop","streamSid":"MZ1","stop":{}}"#).unwrap(),
576            Inbound::Stopped
577        );
578        assert_eq!(
579            parse_inbound(r#"{"event":"connected","protocol":"Call","version":"1.0.0"}"#).unwrap(),
580            Inbound::Connected
581        );
582        // Forward compatibility: an event we don't model is skipped, not fatal.
583        assert_eq!(
584            parse_inbound(r#"{"event":"totally-new-thing"}"#).unwrap(),
585            Inbound::Ignored
586        );
587        assert!(parse_inbound("not json").is_err());
588        assert!(parse_inbound(r#"{"no_event": true}"#).is_err());
589    }
590
591    #[test]
592    fn media_frame_round_trips_through_the_codec() {
593        let samples = vec![0i16, 1000, -1000, 8000];
594        let frame = media_frame("MZ1", &samples);
595        let value: serde_json::Value = serde_json::from_str(&frame).unwrap();
596        assert_eq!(value["event"], "media");
597        assert_eq!(value["streamSid"], "MZ1");
598        let mulaw = base64::engine::general_purpose::STANDARD
599            .decode(value["media"]["payload"].as_str().unwrap())
600            .unwrap();
601        let decoded = g711::decode_ulaw(&mulaw);
602        assert_eq!(decoded.len(), samples.len());
603        // Companded round-trip: close, not exact.
604        for (orig, rt) in samples.iter().zip(&decoded) {
605            assert!(((orig - rt) as i32).abs() <= (orig.unsigned_abs() as i32 / 16).max(16));
606        }
607    }
608
609    #[test]
610    fn clear_and_mark_frames_have_the_wire_shape() {
611        let clear: serde_json::Value = serde_json::from_str(&clear_frame("MZ9")).unwrap();
612        assert_eq!(
613            clear,
614            serde_json::json!({"event": "clear", "streamSid": "MZ9"})
615        );
616        let mark: serde_json::Value = serde_json::from_str(&mark_frame("MZ9", "done")).unwrap();
617        assert_eq!(
618            mark,
619            serde_json::json!({"event": "mark", "streamSid": "MZ9", "mark": {"name": "done"}})
620        );
621    }
622}