Pipecat ships transport serializers for Twilio, Telnyx, Plivo, Exotel, Vonage and Genesys. A developer choosing a phone layer has six options, and jambonz — open source, self-hostable, already capable of forking call audio to a websocket — isn't one of them.
So I wrote it. This post is about the part before the code: finding out what the socket actually does.
Three things I couldn't look up
A FrameSerializer in Pipecat is two methods. serialize turns a Pipecat frame into something to write to the socket; deserialize turns a socket message into a frame. To write either I needed to know:
-
What sample rate does jambonz actually send? The
listenverb takes asampleRate. Does it honour it? -
Is the framing stable? That decides whether
deserializeneeds a variable-length path. -
Does control data share the socket with audio? This is the one that changes the shape of the class. Exotel base64s audio into JSON events, so everything is one stream of JSON. If jambonz interleaves JSON control frames with raw binary audio,
deserializehas to branch on every message.
None of these are in jambonz's docs or Pipecat's. I had an assumption about the first one and I'd already written it into a public README. It was wrong.
The probe
Rather than reason about it, I wrote about 120 lines of aiohttp with two endpoints.
POST / is the call hook — jambonz asks it what to do, and it answers with a single listen verb pointing back at the websocket:
verbs = [{
"verb": "listen",
"url": ws_url,
"mixType": "mono",
"sampleRate": SAMPLE_RATE,
"bidirectionalAudio": {"enabled": True, "streaming": True, "sampleRate": SAMPLE_RATE},
}]
GET /ws receives the forked audio and, crucially, describes it rather than interprets it: how big each frame is, whether the size is stable, whether any TEXT frames arrive, and the byte total divided by the rate we asked for, against wall clock.
That last ratio is the whole point. If I ask for 16 kHz and 30 seconds of wall clock produces 30 seconds of implied audio, the rate is right. If it produces 15, the real rate is half what I asked for.
Then: ngrok, point a jambonz application at it, dial from a softphone, talk for thirty seconds, hang up.
What came back
binary frames : 1514
total bytes : 968960
frame sizes : [640]
text frames : 1
wall clock : 30.03s
audio implied : 30.28s at 16000 Hz, 16-bit mono
ratio : 1.01
The rate the verb asks for is the rate that arrives. Ratio 1.01 across thirty seconds. My assumption had been that jambonz forks at 8 kHz — I'd written that in a public README and it was wrong by a factor of two. It forks at whatever sampleRate the verb set, and the default is 16 kHz.
Framing is fixed at 20 ms. One distinct frame size across 1514 frames: 640 bytes, which is 320 samples of 16-bit mono, which is exactly 20 ms at 16 kHz. No variable-length path needed.
And the third question had a better answer than I expected. Exactly one TEXT frame arrives, before any audio, and then the socket is pure binary for the rest of the call:
{"sampleRate":16000,"mixType":"mono","callSid":"3e5d869a-…",
"direction":"inbound","from":"…","to":"…","sipStatus":200,
"callStatus":"in-progress","traceId":"…"}
It's a preamble, not a channel. So deserialize doesn't branch per frame. It reads one metadata frame at the start and treats everything after it as L16 PCM.
What that does to the design
The sample rate should be read from the wire, not configured. My first spec said the verb and the serializer "have to agree" on a rate. They don't have to — the socket states it. A params field can still override for a narrowband carrier, but the default is to believe the connection. A narrowband call at 8 kHz then needs no code change at all.
I also left it deliberately unset until the preamble arrives, rather than defaulting to 16000. A connection with no preamble then drops audio with a warning, instead of resampling from a rate that was never negotiated — which on a narrowband call produces audio that sounds almost right, and that is the worst kind of wrong.
The preamble carries callSid and traceId for free. That's enough to correlate a Pipecat session with a jambonz call in logs, without threading identifiers through the verb's URL.
And interruptions can't just stop. jambonz has killAudio, which flushes playing and buffered audio. But I asked about the race in the jambonz community Slack and got this from Sam Machin:
the ordering of messages on the socket isn't 100% we've seen clients that sometimes prioritise smaller messages so its best to have a small break after a killAudio before you send the next stream, 50-100ms should be plenty
So a frame written in the same instant as the flush can land after it and play over the caller. Pipecat's interruption model lets a processor stop; over jambonz it has to stop and wait. There's nowhere upstream to put that, so the serializer holds the frames itself for 100 ms — and discards them rather than queueing, because they belong to the turn the caller just interrupted and arriving late is worse than not arriving.
A gotcha worth the whole post
I wrote the tests before the serializer, from the measurements. Three of them failed against correct code.
Pipecat's create_stream_resampler does not emit one chunk per chunk. It buffers and flushes in bursts:
chunk 1: in 640 -> out 0
chunk 2: in 640 -> out 0
chunk 3: in 640 -> out 0
chunk 4: in 640 -> out 3300
Three empty returns, then everything at once. My tests assumed one frame in, one frame out — so serialize returning None looked like a bug when it was the correct answer. Over twenty frames the throughput settles to about 86% of the arithmetic ideal, the rest being the tail still held in the buffer.
If you write a serializer for any transport, don't assert a byte count per call. Pump a run of frames and assert on the total, which is what a real call does anyway.
The thing I'd take away
I got the sample rate wrong by assuming, and it reached a public README. I got the test shape wrong by assuming, and it failed against working code. The protocol questions that mattered were all answered in thirty seconds of one phone call and about 120 lines of throwaway code.
Writing the probe felt like a detour from writing the serializer. It wasn't — it was the serializer. Everything after it was transcription.
The probe, the measurements and the spec:
https://github.com/Rehansanjay/jambonz-bridge
The serializer, in review upstream:
https://github.com/pipecat-ai/pipecat/pull/6112
Top comments (0)