Wavelens

Works anywhere in the world · follow your local laws

Streaming scanner audio over Bluetooth LE without it falling apart

31 Oct 2026 · 8 min read · BLE · audio · engineering

The phone and the Pi talk over a single BLE GATT connection: one characteristic for JSON lines (sweep hits, status), one for audio. Classic Bluetooth A2DP would have been the obvious choice for audio, but the companion is a peripheral that also streams data and has to work from an ESP32; BLE it is.

The codec: IMA ADPCM, 8 kHz

P25 voice comes out of the vocoder as 8 kHz PCM; there's nothing above 4 kHz to preserve. IMA ADPCM compresses that 4:1 (4 bits per sample) with a decoder that is 30 lines of integer code and no library — it runs on an ESP32 as easily as on the phone. The frame is 40 ms: 320 samples → 160 bytes of ADPCM plus a 2-byte sequence/state header plus 2 bytes of predictor = 164 bytes. That fits one notification at a 247-byte MTU, so a frame is never split. 25 frames a second is 4.1 kB/s, a fifth of what BLE delivers.

Four things that made it choppy

  1. Sending frames as they were produced. dsd-fme emits audio in bursts (a 180 ms voice frame at a time). Bursting 5 notifications and then nothing for 150 ms makes BLE's flow control stall. Fix: the Pi runs a 40 ms frame clock and sends one frame per tick, from a small queue.
  2. No buffer on the phone. The first version played each frame as it arrived; any 50 ms of radio jitter was a gap. Fix: a jitter buffer that prefills 5 frames (200 ms) before starting the DAC and inserts silence for any sequence number that never arrived, so the clock never slips.
  3. Bluetooth power saving. Android defaults to a connection interval that may be 50–100 ms. Fix: requestConnectionPriority(CONNECTION_PRIORITY_HIGH) while listening (7.5–15 ms), back to balanced when done.
  4. The HackRF. Parsing hackrf_sweep's output took 94 % of one core on the Pi, and its USB traffic competed with rtl_tcp's. The audio dropped a syllable every few seconds. Fix: the sweep parser runs in a forked process, and the sweep pauses during digital voice.

And one that made it slow and robotic

dsd-fme's UDP output is 16-bit 8 kHz, but stereo for the trunking decoders (one channel per TDMA slot) and mono for single-protocol ones. Reading stereo as mono plays everything at half speed an octave down. We first tried to detect the layout from the sync line, which was wrong for a call that started in one mode; the correct answer was to pin it to the decoder flag, which never changes mid-listen.

What's left

Right now the Pi runs dsd-fme's own high/low/pass-band filters with automatic gain, and the phone adds a 180 Hz high-pass and a soft limiter. For digital voice that's close to the ceiling — the vocoder is the bottleneck, not the link. For analog FM (amateur, business) a 16 kHz Opus stream would sound better than 8 kHz ADPCM; it's a bigger dependency on both ends and the next thing on the list.

The whole protocol — every frame and command — is one page in the companion README, so a different board can implement it.

Try the free Android beta

Everyone who joins the Android beta before the Play Store launch gets the Play version free with a promo code. After that, $8.99, once, on Google Play: no subscription, no in-app purchases, every update included. Fully offline: no account, no ads, no network permission. Nothing leaves your phone.

Join the free Android beta Hear it sooner: radios from $64

282 signatures · 150,000 mapped plate readers · no network permission

Next: Build a $25 analog scanner from an ESP32 and an SA818 module · all articles