Skip to content

Q15 vs UQ15 Quantization

Q15 vs UQ15 spectrum comparison

What you're seeing

All three panels show the spectrum of the same full-scale complex tone (N=65536 samples, Blackman-Harris windowed) at each stage of quantization.

Top — Input CF32. The reference: a single tone at 0.07 cycles/sample, noise floor limited by the float32 compute path (~−150 dBFS).

Middle — Q15 bipolar roundtrip. F32 → int16 → F32 via doppler.cvt.F32ToI16 / I16ToF32. Zero maps to the integer zero; negative values use two's-complement. Noise floor rises to ~−92 dBFS (Q15 theoretical SNR = 6.02 × 15 + 1.76 dB). The tone is otherwise identical to the input.

Bottom — UQ15 offset-binary roundtrip. F32 → v_Q15 + 32768 → uint16 → F32. The +32768 shift (DC in the integer domain) cancels exactly on decode; the noise floor and tone level are indistinguishable from the Q15 panel. This is the encoding convention used by the CIC decimator's internal integer pipeline.

Why two conventions?

Bipolar (Q15): natural for audio, DSP literature, signed arithmetic. Zero is the integer zero; sign-extension preserves the value in wider containers.

Offset-binary (UQ15/UQ16): required when all downstream arithmetic must be unsigned. The CIC integrators use uint64_t accumulators whose overflow behaviour is defined by C99 (mod 2⁶⁴), unlike signed integers which overflow with undefined behaviour. Keeping all inputs non-negative avoids sign-extension casts that are implementation-defined in C99 ((int16_t)(uint16_t)v for v ≥ 32768).

Both conventions use the same quantization step Δ = 2⁻¹⁵, so the noise floor is identical — the choice is purely architectural.

import numpy as np

from doppler.cvt import F32ToI16, F32ToUQ15, I16ToF32, UQ15ToF32

# full-scale complex tone at 0.07 cycles/sample (the demo's signal)
x = np.exp(2j * np.pi * 0.07 * np.arange(65536)).astype(np.complex64)

# Q15 bipolar roundtrip
enc, dec = F32ToI16(), I16ToF32()
x_q15 = dec.steps(enc.steps(x.real))  # real channel only

# UQ15 offset-binary roundtrip: uint16, 0.0 -> 32768
u = F32ToUQ15().steps(x.real)
x_uq15 = UQ15ToF32().steps(u)

# the +32768 offset cancels on decode: the same quantizer, bit for bit
assert np.array_equal(x_q15, x_uq15)
pip install "doppler-dsp[examples]"   # matplotlib + scipy, once
python -m doppler.examples.q15_uq15_demo   # → q15_uq15_demo.png

See docs/design/QUANTIZATION.md for the full mathematical model and C99 cast-chain analysis.