BIQ Python Round Trip
Shrink a live radio stream with a simple Python codec, rebuild it, and see what the compression changes in the spectrum.
Blocks used
Overview
Recording or streaming raw radio samples gets expensive quickly. An RTL-SDR delivers 8-bit I/Q samples over USB.[1] CyberEther hands them to the graph as complex float32, which quadruples the data. At 2 MS/s the radio sends 4 MB/s and the graph moves 16 MB/s.
This flowgraph compresses that stream with block floating point, the same family of techniques that cellular networks use to carry I/Q samples between radio units and baseband processors.[2] A Python block encodes each batch into a compact BIQ1 container,[3] a second Python block decodes it, and a spectrum analyzer shows the reconstructed signal next to an analyzer fed directly by the radio. The encoding is lossy, and the two displays show its effect.
The BIQ format was designed for recording SDR captures, and keeping every block the same size lets a reader seek to any sample by arithmetic alone.[3] The settings used here, 6-bit mantissas in blocks of 256 samples, are its default general profile. Running the flowgraph needs a radio supported by SoapySDR, such as an RTL-SDR.
Note
This flowgraph is based on the BIQ format by Pieter Ibelings.
How it works
Block floating point gives a group of samples one shared exponent and stores each value as a short integer mantissa.[4] The encoder splits every 2,048-sample batch into eight blocks of 256 consecutive samples, or 128 µs of signal at 2 MS/s. For each block it finds the largest I or Q magnitude and picks the smallest exponent that satisfies .[5] Every I and Q value in the block is then rounded to a 6-bit mantissa between and . The decoder rebuilds each value as
The 64-byte BIQ1 header carries the mantissa width, block size, sample rate, and center frequency, so the decoder can unpack the data and the reconstructed analyzer labels its axis correctly.
| Representation | Bytes per batch | Bits per complex sample |
|---|---|---|
| Complex float32 | 16,384 | 64 |
| Native RTL-SDR 8-bit I/Q | 4,096 | 16 |
| BIQ1 container with 6-bit mantissas | 3,144 | 12.3 |
Including the exponents and header, the container is about one fifth the size of the complex float32 batch and 23% smaller than the stream that came over USB. A BIQ recording carries one header for the whole file, while this graph writes a complete container for every batch, so a real file comes out slightly smaller still.
What to look for
Each bit removed from a quantizer costs about 6 dB of signal-to-noise ratio.[6] Because a block spans a slice of time rather than a slice of frequency, the strongest signal anywhere in the 2 MHz window sets the step size for everything else in that block.[5] On a quiet stretch of band the two spectra look nearly identical. Near a strong broadcast station, the station sets the scale and the noise floor of the reconstructed spectrum rises. The file is saved tuned to 96.9 MHz, inside the FM broadcast band, where a strong station is likely to share the window. Both analyzers use the same averaging and display range, so a difference between them comes from the codec.
For a deeper treatment of block floating point and quantization noise, see the sources below.
Going further
To turn the visual comparison into a number, add a Python block that takes the original samples and the reconstructed samples and computes the signal-to-error ratio
Give the block two inputs and no outputs, connect the first to the radio and the second to the decoder, and paste the code below. It prints the ratio to the block console every 250 batches.
import numpy as np
_CALLS = 0
def compute(ctx):
global _CALLS
x = np.asarray(ctx.inputs[0]).reshape(-1)
x_hat = np.asarray(ctx.inputs[1]).reshape(-1)
signal = np.sum(np.abs(x) ** 2)
error = np.sum(np.abs(x - x_hat) ** 2)
_CALLS += 1
if error > 0 and _CALLS % 250 == 1:
print(f"SER: {10 * np.log10(signal / error):.1f} dB")
The decoder reads the mantissa width and block size from the header and accepts widths from 2 to 16 bits. The encoder's packing routine is written for exactly six bits, so trying another width means rewriting it and updating the encoder's declared output size of 3,144 bytes. The block size is fixed in the same way, and both blocks expect batches of 2,048 samples. The Python block reference explains how output shapes are declared.
The codec is written for readability rather than speed. If the displays stall at higher sample rates, the Python blocks are falling behind the radio, which says nothing about compression quality.
References
References
-
Osmocom, "rtl-sdr," project wiki. osmocom.org/projects/rtl-sdr/wiki/Rtl-sdr ↩
-
O-RAN Working Group 4 (Open Fronthaul Interfaces WG) Control, User and Synchronization Plane Specification, O-RAN Alliance, O-RAN.WG4.CUS, Annex A. specifications.o-ran.org ↩
-
P. Ibelings, biq-format, GitHub. github.com/ibelinp/biq-format ↩ ↩2
-
Y. F. Choo, B. L. Evans, and A. Gatherer, "Complex block floating-point format with box encoding for wordlength reduction in communication systems," in Proc. 51st Asilomar Conf. Signals, Syst., Comput., Pacific Grove, CA, USA, Oct. 2017, pp. 1023-1028, doi:10.1109/ACSSC.2017.8335504, arXiv:1705.05217. ↩
-
P. Ibelings, bfp-iq-codec, GitHub. github.com/ibelinp/bfp-iq-codec ↩ ↩2
-
W. Kester, "Taking the mystery out of the infamous formula, 'SNR = 6.02N + 1.76dB,' and why you should care," Analog Devices, Tutorial MT-001, Rev. A. ↩