Skip to main content

flowcat_core/serializer/
plivo.rs

1// SPDX-License-Identifier: Apache-2.0
2//
3//! Plivo `<Stream>` WebSocket serializer.
4//!
5//! Wire shapes are ported from the vendored pipecat `PlivoFrameSerializer`
6//! (`pipecat/src/pipecat/serializers/plivo.py`). Plivo's audio-stream protocol
7//! is JSON **text** frames in both directions; the audio payload is base64
8//! G.711 μ-law @ 8 kHz.
9//!
10//! Inbound (Plivo → us):
11//! ```json
12//! {"event":"start","start":{"streamId":"…","callId":"…",
13//!   "mediaFormat":{"encoding":"audio/x-mulaw","sampleRate":8000}}}
14//! {"event":"media","media":{"payload":"<base64 μ-law>"}}
15//! {"event":"stop"}
16//! ```
17//! Outbound (us → Plivo), matching pipecat's `serialize`:
18//! ```json
19//! {"event":"playAudio","media":{"contentType":"audio/x-mulaw","sampleRate":8000,
20//!   "payload":"<base64 μ-law>"},"streamId":"…"}
21//! {"event":"clearAudio","streamId":"…"}      // barge-in / interruption
22//! ```
23//!
24//! Pure framing only — no I/O.
25
26use base64::Engine;
27use serde_json::{json, Value};
28
29use crate::codec::{pcm16_to_ulaw, ulaw_to_pcm16};
30use crate::serializer::MediaSerializer;
31use crate::types::{AudioChunk, SerIn, WsIn, WsOut};
32
33/// Serializer for Plivo's audio-stream WebSocket protocol.
34#[derive(Debug, Default)]
35pub struct PlivoSerializer {
36    /// Carrier sample rate (Plivo telephony μ-law = 8000).
37    rate: u32,
38    /// The Plivo `streamId`, learned from the `start` frame. Plivo's outbound
39    /// `playAudio`/`clearAudio` frames echo it back (pipecat does the same).
40    stream_id: Option<String>,
41}
42
43impl PlivoSerializer {
44    /// Create a Plivo serializer at the given carrier sample rate (typically 8000).
45    pub fn new(rate: u32) -> Self {
46        Self {
47            rate,
48            stream_id: None,
49        }
50    }
51
52    /// The `streamId` learned from the carrier's `start` frame, if seen.
53    pub fn stream_id(&self) -> Option<&str> {
54        self.stream_id.as_deref()
55    }
56}
57
58impl MediaSerializer for PlivoSerializer {
59    fn on_message(&mut self, msg: &WsIn) -> SerIn {
60        // Plivo speaks JSON over text frames; binary/close carry nothing here.
61        let text = match msg {
62            WsIn::Text(t) => t,
63            WsIn::Close => return SerIn::Stop,
64            WsIn::Binary(_) => return SerIn::Ignore,
65        };
66
67        let v: Value = match serde_json::from_str(text) {
68            Ok(v) => v,
69            // A malformed/non-JSON text frame is not actionable; don't panic.
70            Err(_) => return SerIn::Ignore,
71        };
72
73        match v.get("event").and_then(Value::as_str) {
74            Some("start") => {
75                let start = v.get("start").cloned().unwrap_or(Value::Null);
76                let stream_id = start
77                    .get("streamId")
78                    .and_then(Value::as_str)
79                    .map(str::to_owned);
80                // `callId` is the carrier call id; fall back to streamId so we
81                // always surface *some* identifier to the pipeline.
82                let call_id = start
83                    .get("callId")
84                    .and_then(Value::as_str)
85                    .map(str::to_owned)
86                    .or_else(|| stream_id.clone())
87                    .unwrap_or_default();
88                self.stream_id = stream_id.clone();
89                SerIn::StreamStart { call_id, stream_id }
90            }
91            Some("media") => {
92                let payload = v
93                    .get("media")
94                    .and_then(|m| m.get("payload"))
95                    .and_then(Value::as_str);
96                match payload {
97                    Some(b64) => match base64::engine::general_purpose::STANDARD.decode(b64) {
98                        Ok(ulaw) => SerIn::Audio(AudioChunk {
99                            pcm: ulaw_to_pcm16(&ulaw),
100                            sample_rate: self.rate,
101                        }),
102                        Err(_) => SerIn::Ignore,
103                    },
104                    None => SerIn::Ignore,
105                }
106            }
107            Some("stop") => SerIn::Stop,
108            // `dtmf`, `mark`/checkpoint acks, clears, unknown events: no action.
109            _ => SerIn::Ignore,
110        }
111    }
112
113    fn encode_audio(&self, chunk: &AudioChunk) -> WsOut {
114        // Outbound to Plivo is μ-law @ the carrier rate. The pipeline is
115        // expected to hand us a chunk already at `self.rate`; we encode as-is
116        // (resampling is the codec/pipeline's job, mirroring pipecat which
117        // resamples before serializing).
118        let ulaw = pcm16_to_ulaw(&chunk.pcm);
119        let payload = base64::engine::general_purpose::STANDARD.encode(ulaw);
120        let mut answer = json!({
121            "event": "playAudio",
122            "media": {
123                "contentType": "audio/x-mulaw",
124                "sampleRate": self.rate,
125                "payload": payload,
126            },
127        });
128        // Echo the streamId when known (pipecat always sets it).
129        if let Some(sid) = &self.stream_id {
130            answer["streamId"] = Value::String(sid.clone());
131        }
132        WsOut::Text(answer.to_string())
133    }
134
135    fn encode_clear(&self) -> Option<WsOut> {
136        // pipecat: `{"event":"clearAudio","streamId": <stream_id>}` on interruption.
137        let mut answer = json!({ "event": "clearAudio" });
138        if let Some(sid) = &self.stream_id {
139            answer["streamId"] = Value::String(sid.clone());
140        }
141        Some(WsOut::Text(answer.to_string()))
142    }
143
144    fn carrier_rate(&self) -> u32 {
145        self.rate
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    fn b64(bytes: &[u8]) -> String {
154        base64::engine::general_purpose::STANDARD.encode(bytes)
155    }
156
157    #[test]
158    fn start_event_parses_to_stream_start_with_ids() {
159        let mut s = PlivoSerializer::new(8000);
160        let frame = WsIn::Text(
161            json!({
162                "event": "start",
163                "start": {
164                    "streamId": "strm-123",
165                    "callId": "call-abc",
166                    "mediaFormat": {"encoding": "audio/x-mulaw", "sampleRate": 8000}
167                }
168            })
169            .to_string(),
170        );
171        match s.on_message(&frame) {
172            SerIn::StreamStart { call_id, stream_id } => {
173                assert_eq!(call_id, "call-abc");
174                assert_eq!(stream_id.as_deref(), Some("strm-123"));
175            }
176            other => panic!("expected StreamStart, got {other:?}"),
177        }
178        // streamId is now remembered for outbound framing.
179        assert_eq!(s.stream_id(), Some("strm-123"));
180    }
181
182    #[test]
183    fn media_event_base64_ulaw_decodes_to_pcm() {
184        let mut s = PlivoSerializer::new(8000);
185        // Build the μ-law input by encoding known PCM so the wiring is exercised
186        // end-to-end. (μ-law byte→byte is *not* bijective, so we compare against
187        // the codec's own decode of the same bytes rather than re-encoding.)
188        let ulaw_in = vec![0xFFu8, 0x00, 0x80, 0x7F];
189        let expected_pcm = ulaw_to_pcm16(&ulaw_in);
190        let frame =
191            WsIn::Text(json!({"event": "media", "media": {"payload": b64(&ulaw_in)}}).to_string());
192        match s.on_message(&frame) {
193            SerIn::Audio(chunk) => {
194                assert_eq!(chunk.sample_rate, 8000);
195                // One PCM sample per μ-law byte, decoded via the G.711 codec.
196                assert_eq!(chunk.pcm.len(), ulaw_in.len());
197                assert_eq!(chunk.pcm, expected_pcm);
198            }
199            other => panic!("expected Audio, got {other:?}"),
200        }
201    }
202
203    #[test]
204    fn stop_event_and_close_map_to_stop() {
205        let mut s = PlivoSerializer::new(8000);
206        assert!(matches!(
207            s.on_message(&WsIn::Text(json!({"event": "stop"}).to_string())),
208            SerIn::Stop
209        ));
210        assert!(matches!(s.on_message(&WsIn::Close), SerIn::Stop));
211    }
212
213    #[test]
214    fn unknown_and_malformed_frames_are_ignored() {
215        let mut s = PlivoSerializer::new(8000);
216        assert!(matches!(
217            s.on_message(&WsIn::Text(json!({"event": "dtmf"}).to_string())),
218            SerIn::Ignore
219        ));
220        assert!(matches!(
221            s.on_message(&WsIn::Text("not json".to_string())),
222            SerIn::Ignore
223        ));
224        assert!(matches!(
225            s.on_message(&WsIn::Binary(vec![1, 2, 3])),
226            SerIn::Ignore
227        ));
228    }
229
230    #[test]
231    fn encode_audio_has_plivo_keys_and_round_trippable_payload() {
232        let mut s = PlivoSerializer::new(8000);
233        // Learn a streamId first so it's echoed in the outbound frame.
234        s.on_message(&WsIn::Text(
235            json!({"event": "start", "start": {"streamId": "S1", "callId": "C1"}}).to_string(),
236        ));
237
238        let pcm = vec![0i16, 100, -100, 32000, -32000];
239        let out = s.encode_audio(&AudioChunk {
240            pcm: pcm.clone(),
241            sample_rate: 8000,
242        });
243        let text = match out {
244            WsOut::Text(t) => t,
245            other => panic!("expected Text, got {other:?}"),
246        };
247        let v: Value = serde_json::from_str(&text).unwrap();
248        assert_eq!(v["event"], "playAudio");
249        assert_eq!(v["media"]["contentType"], "audio/x-mulaw");
250        assert_eq!(v["media"]["sampleRate"], 8000);
251        assert_eq!(v["streamId"], "S1");
252
253        // The payload is base64 μ-law of the input PCM (codec round-trip).
254        let payload = v["media"]["payload"].as_str().unwrap();
255        let ulaw = base64::engine::general_purpose::STANDARD
256            .decode(payload)
257            .unwrap();
258        assert_eq!(ulaw, pcm16_to_ulaw(&pcm));
259        // Decoding back yields the same number of samples.
260        assert_eq!(ulaw_to_pcm16(&ulaw).len(), pcm.len());
261    }
262
263    #[test]
264    fn encode_clear_is_plivo_clear_audio() {
265        let mut s = PlivoSerializer::new(8000);
266        s.on_message(&WsIn::Text(
267            json!({"event": "start", "start": {"streamId": "S9"}}).to_string(),
268        ));
269        let out = s.encode_clear().expect("plivo supports clearAudio");
270        let text = match out {
271            WsOut::Text(t) => t,
272            other => panic!("expected Text, got {other:?}"),
273        };
274        let v: Value = serde_json::from_str(&text).unwrap();
275        assert_eq!(v["event"], "clearAudio");
276        assert_eq!(v["streamId"], "S9");
277    }
278}