A recording format for SDR IQ that is 2–4× smaller than the 16-bit WAV files everyone uses, and still seeks to any sample in constant time.
.biq is a thin container around block floating
point: a 64-byte header, an optional JSON
metadata blob, then a stream of fixed-size BFP blocks. That's the whole format.
SPEC.md is the normative description and fits in one sitting.
Every IQ recording format in common use stores raw samples at a fixed scale.
cu8 off an RTL dongle is 2 bytes a sample. ci16 — WAV with a SpectraVue
auxi chunk, RF64, SigMF ci16_le, Rohde & Schwarz .wv, headerless .cs16 —
is 4. GNU Radio's cf32 is 8. They differ in depth, and in how they spell the
sample rate and the centre frequency, and in nothing else that matters: the
scale is the same whatever the signal is doing. A 24-hour capture at 5 Msps is
864 GB, 1.7 TB, or 3.5 TB depending which one you picked.
The obvious fix is to compress, and the obvious compressors are all wrong for this. FLAC and gzip are variable-rate and stateful, so you can't seek without an index and you can't predict the size. Dropping to 8 bits works but wastes range: a scale sized for the loud case quantises the quiet case near the bottom. Server-side AGC destroys the levels you wanted to record in the first place.
Block floating point sidesteps all three. A block of N samples shares one
exponent and stores a b-bit mantissa per component, so the scale follows the
signal — and the exponent is bookkeeping the decoder undoes, not a gain
decision. At 6 bits it is 2.7× smaller than ci16; that same 24-hour capture is
650 GB.
Holding N and b constant for a whole file is the design decision the rest of
the format hangs on. It costs nothing (change parameters at a segment boundary
instead) and it buys four things no compressed format usually has:
Constant-time seek. No index, no scan — the offset of any sample is arithmetic:
offset(s) = data_start + (s / N) * block_stride
The file is memory-mappable and parallel-decodable, and any block decodes with no reference to any other.
A free amplitude envelope. Every block carries its exponent at a fixed
stride, so reading only the exponent bytes — one strided pass, filesize / block_stride bytes — gives you a log-domain envelope of the entire recording at
N-sample resolution without decoding a single mantissa. Roughly 20 kB per second
of recording. That's your scrub bar and your "where's the burst in this 40 GB
file", for free, with no sidecar index.
Truncation that just works. data_bytes = 0 legally means "runs to EOF", so
a recorder killed by a crash or a full disk leaves a fully readable file minus
at most the final partial block. A WAV in the same situation has a bogus
dataSize and needs repair tooling.
Time that survives damage. Because the stride is fixed, a sample's index is positional — so corruption in the middle of a file cannot desync the index, and samples after the damage are still at the right timestamp. The time reference itself sits at byte 0 and is written before the first sample, so truncation never reaches it. And every segment carries its own clock reading rather than counting forward from the first one (SPEC §10.1), so a lost or corrupt segment leaves the rest of the series correctly timed and error cannot accumulate across a recording.
The failure this doesn't absorb is samples the source dropped without the recorder noticing — the file then looks continuous while everything after the gap runs late, which is worse than an obvious failure. The spec's answer is to roll a new segment at the gap so the drop becomes an ordinary boundary with a fresh, correct-by-construction timestamp, rather than a note in the metadata that a dying recorder might never write.
Bytes per complex sample is b/4 + 1/N. At N = 256:
| profile | b | bytes/sample | vs. int16 | 25 s @ 5 Msps | 24 h @ 5 Msps | intended use |
|---|---|---|---|---|---|---|
survey |
4 | 1.004 | 4.0× | 125 MB | 434 GB | wideband monitoring, long unattended captures |
general |
6 | 1.504 | 2.7× | 188 MB | 650 GB | default. Weak-signal, CW, digital modes |
archive |
8 | 2.004 | 2.0× | 250 MB | 866 GB | keep-forever captures |
| — | int16 | 4.0 | 1.0× | 500 MB | 1.7 TB | what you're writing today |
One mantissa width governs both I and Q — the two components of a complex
baseband sample have identical statistics, so splitting the budget between them
buys nothing. Peak SQNR is the textbook 6.02·b + 1.76 dB, measured against the
block's own peak rather than a fixed system full-scale, which is exactly why a
weak signal keeps its precision.
The 64-byte header carries what you need to decode and to know what you're
looking at: sample rate and centre frequency as f64 (the SpectraVue auxi
chunk's u32 Hz dies above 4.29 GHz), UTC start time in nanoseconds, and
ref_dbm_full_scale so playback shows the same S-meter reading the live
receiver did. That last one is the field none of the existing formats have.
Everything else goes in the JSON blob, which reuses SigMF's key names
verbatim (core:sample_rate, core:frequency, core:datetime,
core:geolocation, …). Converting to and from a .sigmf-meta sidecar is
mechanical, and nobody has to learn a second metadata vocabulary. Retunes and
dropouts are sample-indexed entries in biq:events, never inline in the block
stream — keeping the stream pure is what preserves the fixed stride.
cd reference && make # builds biqconv, no dependencies
./biqconv capture.wav capture.biq -b 6
./biqconv info capture.biq
./biqconv capture.biq back.wavMeasured on a 25-second 5 Msps FM-band capture from an ADALM-Pluto:
capture.wav -> capture.biq
125009920 samples 100 MHz 5 Msps b=6 N=256
500.04 MB -> 188.00 MB 2.66x smaller
Round-tripped back to 16-bit WAV, that file measures 29.2 dB SNR against the original with a worst-case single-sample error of 4 LSB out of 32768 — which is what 6-bit mantissas against this signal's crest factor predicts.
biqconv info reads the whole recording's amplitude envelope from the exponent
bytes alone. On the 188 MB file that is 488,320 bytes touched and no mantissas
decoded.
reference/ is dependency-free and self-testing.
| file | what it is |
|---|---|
biq.hpp |
header-only C++11 encoder, decoder, Writer, Reader. Drop it in. |
biq.py |
the spec in Python — literal, slow, good for notebooks |
biqconv.cpp |
the CLI converter |
biq_test.cpp |
1369 checks: packing, exponents, headers, seek, truncation, envelope |
golden/ |
test vectors from an independent Rust implementation |
make test # C++ and Python, both against the golden vectorsThe golden vectors are the part worth caring about if you write your own port.
They were produced by a separate Rust encoder, and both reference
implementations reproduce them byte for byte at b = 2, 4, 5, 6, 8, and 16 —
covering byte-aligned widths, widths that straddle two bytes, and widths that
straddle three. Interop on this format means agreeing on LSB-first bit packing
and on which exponent the encoder picks; the vectors pin down both. If your
implementation matches golden/, it will read anyone's files.
(On the exponent: ceil(log2(peak / m_max)) is the rule, but a libm that rounds
log2 the other way at an exact power of two picks e±1. Both are legal —
decoders never depend on the encoder's choice — so all three implementations
settle it with exact float compares instead, and agree everywhere.)
Draft. Implementations work and agree; the spec is not frozen until it has been used in anger.
-
SPEC.md— format definition -
reference/biq.hpp— header-only C++ encoder/decoder -
reference/biq.py— Python reference, matching byte for byte -
biqconv— CLI converter:wav ↔ biq,sigmf ↔ biq,cs16 ↔ biq - multi-channel (the
channelsbyte is reserved, MUST be 1 today) - a recorder writing
.biqlive rather than converting after the fact
bfp-iq-codec explains block floating
point itself — the technique, the exponent rule, how to choose b, and how it
compares to block scaling and to entropy coders. It is the reading for why.
This repo is the file: a container around blocks that are byte-identical to the
ones bfp-iq-codec defines for streaming. One codec, two transports. If you have
already implemented bfp-iq-codec, adding .biq is a header parser and a seek
formula.
MIT. See LICENSE. Use it, ship it, port it.
There is nothing here to own. Block floating point is decades-old textbook DSP, and this is sixty-four bytes of header wrapped around it. The spec is deliberately small enough to implement in an afternoon, the metadata keys are SigMF's rather than mine because the world does not need a second vocabulary for "sample rate", and the golden vectors are checked in so nobody has to take my word for what the bytes should be. If the container doesn't suit you, take the ideas out of it — the fixed stride, the exponent envelope, the header that survives a truncated file — and put them somewhere better.
I wrote it down because IQ is the raw material of everything interesting in radio, and we throw it away constantly. Captures deleted to free a disk. The good five seconds kept and all the context around them lost. Band surveys never run because a week of spectrum is a wall of terabytes. Being four times smaller doesn't change what's possible in principle — it changes what people actually keep. If this means one more recording survives, or somebody hands over a whole capture instead of a screenshot of it, it did its job.