WHAT YOU ARE LOOKING AT
Noise-shaping modulators have a reputation for being mysterious. They are
six lines of Python. For each input sample, work out the quantizer input
x — the previous quantizer input plus the difference between the
current input and the previous output — then quantize it, optionally
with a bit of dither added.
Write the loop out in the z-domain with the quantization error called E and
the algebra collapses to Y = U + E·(1 − z-1).
The signal transfer function is 1: the input passes straight through. The
noise transfer function is a differentiator: it is zero at DC and rises at
20 dB/decade, so the error gets pushed out of the low frequencies and piled
up near fs/2. If you only care about a small band near DC, the
error you kept is much smaller than the error a plain quantizer would have
left there.
The top row is the loop: input and modulator output, then the integrator
state, then the output decoded back — a sinc2 average of
the same coarse samples, which lands on the input again. The bottom row is
why it works: the two spectra, the shaping law, and what it buys per octave
of oversampling.
One caveat about the word “one-bit”. A quantizer that rounds to
a grid but never saturates (np.round(x*2**bits)/2**bits, which
is what ex/sd_1st.py did before it was fixed) emits seven
levels at bits = 1 rather than two, and its output is not a bitstream at
all. This page and the script both use a proper B-bit quantizer whose
2B levels reach ±1 — at B = 1 that is
sign(x), a genuine comparator. The output readout
counts the levels that actually came out, and ticking use the unclamped
quantiser reproduces the old behaviour.
THINGS WORTH TRYING
-
Compare the two SNR readouts. At OSR 16 the noise-shaped output
beats the plain one-bit quantizer by around 20 dB, from exactly the same
two-level converter. All that changed is where the error was put.
-
Push the amplitude to 1.0 and sweep the OSR again. The neat 9
dB/octave falls apart — 7, then 4, then 13. A one-bit first-order
loop cannot take a full-scale input: with only two levels to work with,
the integrator state runs away between corrections and the modulator
overloads. Real one-bit designs back off several dB from full scale, which
is why this page starts at 0.7. Watch |x| max as you go.
-
Sweep OSR and watch the two curves in the SNR panel. Theory says
3 dB per octave for a plain quantizer and 9 dB per octave for a
first-order modulator, because the shaped noise is not flat and the band
you throw away is where all of it went. The shaped curve does track 9
dB/octave. The plain curve does something stranger: almost nothing for
the first few octaves, then a sudden jump of nearly 30 dB. Its error is
harmonics, not white noise, so narrowing the band buys you nothing at all
until the edge finally passes the third harmonic, at which point that
harmonic leaves the band and the number leaps. Averaging arguments assume
noise; this is what happens when the error is not.
-
Tick use the unclamped quantiser. The output readout jumps from two
levels to seven, the SNR moves, and what you are modulating is no longer a
one-bit converter. Everything after the modulator is unchanged: the same
sinc2 average reconstructs the sine either way, which is a
decent hint at how forgiving the decimator is.
-
Take dither to 0. Look at the low-frequency end of the log-frequency
panel: spurs appear. A one-bit loop with a slowly changing input falls into
repeating patterns, and those patterns are tones, not noise. Adding dither
breaks them up at the cost of a slightly higher floor. This is the tradeoff
the script's
dither flag is there to show.
-
Push the amplitude to 1.0 and watch |x| max, the integrator
state. A single-bit first-order loop stays stable, but the state swings
well beyond full scale, and in a real circuit that integrator has to have
somewhere to put it. Drop the amplitude and the swing comes back down.
-
The green trace is the same converter with the loop taken away, on every
panel. For the unshaped case in more detail see the
oversampling example; for second-order and
leapfrog loops with a real decimator attached, see
cicadc.
THE PYTHON
dither = 1
M = len(u)
y_sd = np.zeros(M)
x = np.zeros(M)
for n in range(1,M):
x[n] = x[n-1] + (u[n]-y_sd[n-1])
y_sd[n] = np.round(x[n]*2**bits + dither*np.random.randn()/4)/2**bits
Source: ex/sd_1st.py.
The script trims the first two samples to skip the settling; this page keeps
them, because the Hann window is close to zero there anyway.