BDChat exists so Bangladeshi expatriates can call home. That one sentence contains the whole engineering problem: one end of the call is on good Wi-Fi in Riyadh or Rome, and the other end is often on rural 3G, on a phone from 2019, during evening network congestion. When our support inbox filled with "calls sound like robots," the timestamps clustered around 3 a.m. our time — evening in the Gulf, when the diaspora calls their mothers.
We spent a week convinced Agora's SDK was the problem. It wasn't. The fix was five lines away in our own reconnection logic, and finding it taught me more about real-time audio than a year of normal feature work.
Step 1 — reproduce a network you can't buy in an office
You cannot debug rural 3G from an office with fibre. Our first two days were wasted trying — the bug never showed on our network. What worked was putting a Linux box in the call path as a Wi-Fi gateway and shaping traffic with tc netem until it matched what our victims actually experienced:
# "evening rural 3G": constrained, jittery, lossy, occasionally gone
tc qdisc add dev wlan0 root handle 1: tbf rate 380kbit burst 32kbit latency 400ms
tc qdisc add dev wlan0 parent 1: handle 10: netem \
delay 220ms 150ms distribution normal \
loss 4% 25% \
reorder 2%
The parameters weren't invented. We pulled them from Agora's own call-quality analytics for our worst-performing regions — median round-trip, jitter spread, loss bursts — and tuned until our office calls scored like theirs. Within an hour of getting the profile right, we had the robot voice on demand. If you take one thing from this post: the reproduction is the debugging. Everything after this step took three days; everything before it had produced nothing in two.
Step 2 — learn what the jitter buffer is actually doing
A quick primer, because the fix makes no sense without it. Voice arrives as packets every 20ms, but a bad network delivers them late, early, out of order, or in bursts. The jitter buffer is a small holding area that trades latency for smoothness: it delays playback slightly so it can reorder and pace whatever arrives. Crucially, it is adaptive — it watches the network and grows during rough patches, shrinks during calm ones. It takes seconds of observation to size itself well.
With the testbed running, we logged what the SDK was seeing:
engine.registerEventHandler(RtcEngineEventHandler(
onRemoteAudioStats: (connection, stats) {
_log('loss=${stats.audioLossRate}% '
'jitter=${stats.jitterBufferDelay}ms '
'frozen=${stats.frozenRate}%');
},
));
And here the data exonerated the SDK. On a stable bad network — high jitter, steady 4% loss — Agora sounded rough for the first few seconds, then the buffer adapted and the call was genuinely fine. The robot voice only appeared around connection drops. That moved the suspicion from their code to ours.
Step 3 — find the bug in the mirror
Our connection handler, written months earlier with the best intentions:
// the old handler — every wobble triggered a full manual rejoin
onConnectionStateChanged: (conn, state, reason) async {
if (state == ConnectionStateType.connectionStateReconnecting) {
await engine.leaveChannel(); // throws away the adapted jitter buffer
await _joinWithFreshToken(); // 3–8 seconds of silence on 3G
}
},
The author had seen a stuck call once and decided not to trust the SDK's recovery. So the moment Agora reported Reconnecting — which on rural 3G happens for two-second blips constantly — we'd tear the whole channel down and rejoin. Every rejoin threw away the jitter buffer's minutes of learned network wisdom, forcing it to restart cold on the worst possible network. Cold buffer on a jittery link is exactly the robot voice. And because rejoining took seconds, we often rejoined into the next blip, looping the misery.
The SDK was recovering from the network. Our code was preventing it from recovering from us.
Step 4 — replace panic with a state machine
The fix was to stop acting on instant transitions and model the call explicitly, with patience built in:
enum CallState { connected, degraded, reconnecting, ended }
void _onConnectionState(ConnectionStateType state) {
switch (state) {
case ConnectionStateType.connectionStateReconnecting:
_set(CallState.reconnecting);
_banner.show('Reconnecting…'); // tell the user, do nothing else
_giveUp = Timer(const Duration(seconds: 30), _endCall);
case ConnectionStateType.connectionStateConnected:
_giveUp?.cancel();
_set(CallState.connected);
_banner.hide();
default:
break;
}
}
The degraded state exists purely for honesty: when loss climbs past a threshold we show a weak-connection indicator, because users forgive bad networks but not silent apps. The only path that abandons a call is thirty full seconds without transport — and the SDK, left alone, beat that deadline in the overwhelming majority of drops.
What shipped, in numbers
On the netem testbed, robot-voice episodes per test hour went from reliably reproducible to zero across two hundred scripted drop cycles. In production over the following month, call-quality complaints fell by roughly 80%, and average call duration in our worst regions went up — the clearest sign, since people don't stay on calls that hurt to listen to.
The postmortem lesson wasn't "trust the SDK", exactly. It was narrower and more useful: an adaptive system carries state that is expensive to rebuild, so code that resets an adaptive system should have to justify itself. Our handler reset one every time the network sneezed — at 3 a.m., on rural 3G, between a construction worker in Dammam and his mother's evening. The bug report said the calls sounded like robots. The truth was that our code kept interrupting the one component patient enough to listen.