Dither on Hardware

Adding TPDF dither before a DAC on a microcontroller

The main page shows dither in Python: add TPDF noise before quantising, the harmonic peaks disappear, the spectrum is clean. On a microcontroller you can do exactly the same thing: generate TPDF dither from two independent noise sources, add it before the DAC write, and the output spectrum is free of truncation distortion. The cost is two LFSRs, one addition, and one shift per sample.

This page implements the full dithered quantiser chain for the two default platforms (ESP32-S3 and NUCLEO-F446RE, per ADR-005). The building block (a 16-bit Galois LFSR) is borrowed from the noise generation embedded page.


Why you would do this

An embedded system that reduces bit depth (a 16-bit filtered sample going to a 12-bit DAC, or a 24-bit ADC value truncated to 16 bits for storage) creates signal-correlated quantisation error. The harmonics are not subtle: they appear as unexpected tones in the output spectrum, and they change with the signal amplitude. Adding TPDF dither before the truncation replaces the tones with a flat, ~3 dB higher noise floor. In audio, the perceptual difference is the difference between “harsh” and “clean.” In sensor systems, a flat noise floor is easier to characterise and subtract than harmonic lines.


TPDF dither from two LFSRs

TPDF dither is the sum of two independent uniform (RPDF) noise sources, each spanning \([-Q/2, Q/2]\), where \(Q\) is the quantisation step. On an MCU, a 16-bit LFSR produces a uniform pseudo-random sequence with a long period (primitive polynomial \(x^{16} + x^{14} + x^{13} + x^{11} + 1\), polynomial 0xB400). Two independent LFSRs summed give a triangular-distributed output on \([-Q, Q]\).

// TPDF dither generator for an N-bit quantiser.
// Q = V_ref / (2^N - 1) is the LSB size.
// Each LFSR generates uniform noise on [-Q/2, Q/2];
// the sum of two gives TPDF on [-Q, Q].

#include <stdint.h>

typedef struct {
    uint16_t lfsr1;
    uint16_t lfsr2;
    float    q;  // LSB size (quantisation step)
} TPDFDither;

// Initialise with two non-zero seeds.  Seeds must differ to produce
// independent streams; using the same seed gives RPDF not TPDF.
void tpdf_init(TPDFDither *d, uint16_t seed1, uint16_t seed2, float q) {
    // XOR keeps every seed bit significant (OR would pin the constant's
    // set bits to 1 and collapse most seeds onto the same state).
    d->lfsr1 = seed1 ^ 0xACE1u;
    d->lfsr2 = seed2 ^ 0x1357u;
    if (d->lfsr1 == 0) d->lfsr1 = 1;
    if (d->lfsr2 == 0) d->lfsr2 = 1;
    d->q = q;
}

// Return one TPDF dither sample on [-Q, Q].
float tpdf_next(TPDFDither *d) {
    // Update LFSR1 (Galois, poly 0xB400).
    uint16_t lsb1 = d->lfsr1 & 1u;
    d->lfsr1 >>= 1;
    if (lsb1) d->lfsr1 ^= 0xB400u;

    // Update LFSR2.
    uint16_t lsb2 = d->lfsr2 & 1u;
    d->lfsr2 >>= 1;
    if (lsb2) d->lfsr2 ^= 0xB400u;

    // Map to [-Q/2, Q/2] for each LFSR, sum for TPDF on [-Q, Q].
    float r1 = ((float)d->lfsr1 / 65535.0f - 0.5f) * d->q;
    float r2 = ((float)d->lfsr2 / 65535.0f - 0.5f) * d->q;
    return r1 + r2;
}

The TPDF output is triangular-distributed on \([-Q, Q]\) with variance \(Q^2/6\), exactly the property that eliminates both quantisation distortion and noise modulation.


Dithered quantisation on the STM32F4 DAC

The STM32F4’s 12-bit DAC output is a natural place for dither. A 16-bit filtered sample truncated to 12 bits creates harmonic distortion; adding TPDF dither before writing to the DAC data holding register (DHR) cleans the spectrum.

// NUCLEO-F446RE: dithered 16→12 bit quantisation before DAC output.
// Assumes the DAC is configured (DAC1, channel 1, TIM6-triggered DMA).
// The processed sample arrives as a float in [-1, 1] from the DSP chain.

#define DAC_BITS 12
#define DAC_VREF 3.3f
#define DAC_Q    (DAC_VREF / (float)((1 << DAC_BITS) - 1))

static TPDFDither dither;

void dac_dithered_init(void) {
    // Use two different ADC noise-floor readings as seeds for independence.
    tpdf_init(&dither, 0x3A7F, 0x8C21, DAC_Q);
}

void dac_write_dithered(float sample) {
    // Add TPDF dither before quantising.
    float dithered = sample * (DAC_VREF / 2.0f) + (DAC_VREF / 2.0f)  // to 0..Vref
                     + tpdf_next(&dither);

    // Clip to DAC range and quantise.
    if (dithered < 0.0f) dithered = 0.0f;
    if (dithered > DAC_VREF) dithered = DAC_VREF;

    uint16_t dac_val = (uint16_t)(dithered / DAC_Q + 0.5f);
    if (dac_val > 4095) dac_val = 4095;

    DAC->DHR12R1 = dac_val;
}

Without dither, a pure sine at the DAC output will show harmonic spurs at integer multiples of the fundamental. With TPDF dither, the spurs are gone and the noise floor rises by ~5 dB: the exact trade-off the main page demonstrates in simulation.


Verifying with a spectrum analyser

If you have a PC soundcard or a USB spectrum analyser connected to the DAC output:

  1. Generate a pure 1 kHz sine in firmware.
  2. Output it via the DAC without dither. Measure the spectrum: note the harmonic peaks.
  3. Enable dither. The harmonics vanish and the noise floor rises slightly.
  4. Try RPDF-only dither (comment out one LFSR). The harmonics are reduced but noise modulation remains; the noise floor dips during quiet passages.

This is one of the few DSP experiments you can verify with your ears: a 16→12 bit truncation of a sine wave without dither has an audible harshness at low amplitudes; with TPDF dither it sounds clean.


ESP32-S3

The ESP32-S3 has no DAC peripheral. The original ESP32 and the S2 had two 8-bit DAC channels; the S3 dropped them, so ESP-IDF’s DAC driver does not even build for this target. The built-in route to an analog output on the S3 is LEDC PWM: an 8-bit duty cycle at a high carrier frequency, followed by an RC low-pass filter, behaves like an 8-bit DAC. That makes the dither case more dramatic than on the STM32: 256 levels instead of 4096, so undithered quantisation creates strong harmonic lines.

The same tpdf_next() function works unchanged. The quantisation step is now one PWM duty step, which in normalised full-scale units is \(Q = 1/255\):

// ESP32-S3: dithered 16->8 bit quantisation before an 8-bit LEDC PWM
// "DAC" (PWM at a high carrier frequency + external RC low-pass).
// The S3 has no DAC peripheral; its LEDC has only low-speed mode.
#include "driver/ledc.h"

#define PWM_BITS 8
#define PWM_Q    (1.0f / (float)((1 << PWM_BITS) - 1))  // 1 LSB, full scale = 1.0

static TPDFDither dither_pwm;

void pwm_dither_init(void) {
    // LEDC timer/channel setup (8-bit duty) not shown.
    tpdf_init(&dither_pwm, 0x3A7F, 0x8C21, PWM_Q);
}

// sample in [-1, 1] -> dithered 8-bit duty.
void pwm_write_dithered(float sample) {
    float dithered = sample * 0.5f + 0.5f        // to [0, 1] full scale
                     + tpdf_next(&dither_pwm);   // TPDF on [-Q, Q]
    if (dithered < 0.0f) dithered = 0.0f;
    if (dithered > 1.0f) dithered = 1.0f;

    uint32_t duty = (uint32_t)(dithered * 255.0f + 0.5f);
    ledc_set_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0, duty);
    ledc_update_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0);
}

When NOT to dither

Dither adds ~5 dB of noise. If your system is already thermal-noise-limited at the DAC output, dither makes it worse with no benefit. If the downstream system averages many DAC samples (e.g., a slow ADC reading a DAC-generated reference), the dither noise averages out and the distortion does not, so dither helps. The decision is: is your output listened to (ears care about harmonics more than noise) or measured (averaging removes noise; harmonics are systematic error)?

References