Difficulty: Intermediate | Time: 45 Minutes | Cost: ~$8 USD

To extract a stable DC or analog waveform from an ESP32’s digital PWM pin, you need a first-order RC low-pass filter. For a standard 5kHz PWM signal, pairing a 1kΩ resistor with a 10µF capacitor yields a cutoff frequency of roughly 15.9Hz. This effectively smooths the 3.3V logic pulses into a clean analog voltage with less than 35mV of peak-to-peak ripple, bridging the gap between digital logic and analog control in your diy projects electronics builds.

The Core Theory: Why PWM Needs an RC Filter

Pulse Width Modulation (PWM) is not actually an analog signal; it is a high-frequency square wave that toggles between 0V and 3.3V. The ESP32 achieves the illusion of analog voltage by varying the duty cycle (the percentage of time the pin is HIGH). If you connect an LED to a PWM pin, your eye's persistence of vision acts as a low-pass filter, averaging the light output. However, if you feed that same PWM signal into an op-amp, a motor driver, or an ADC, the downstream circuit will see the harsh 3.3V/0V switching edges, causing noise, EMI, and erratic behavior.

To convert this square wave into a true DC voltage, we use a passive RC (Resistor-Capacitor) low-pass filter. The resistor restricts current flow, while the capacitor stores charge.

The Water Analogy: Imagine a pulsing water pump (the PWM signal) pushing water through a narrow pipe (the resistor) into a flexible rubber bladder (the capacitor). The narrow pipe prevents the bladder from inflating instantly, and the bladder's elasticity smooths out the pulses, resulting in a steady, continuous pressure (DC voltage) at the output.

The governing equation for the cutoff frequency ($f_c$) — the point where the signal power drops by 3dB — is:

$f_c = \frac{1}{2 \pi R C}$

For effective smoothing in diy projects electronics, your filter's cutoff frequency must be significantly lower than your PWM switching frequency. A standard engineering rule of thumb is to set $f_c$ at least 10 to 50 times lower than the PWM frequency ($f_{pwm}$) to minimize output ripple.

Component Selection & Ripple Voltage Matrix

Choosing the right R and C values is a trade-off between ripple voltage (how smooth the output is) and settling time (how fast the output voltage can change when you update the PWM duty cycle). The time constant ($\tau = R \times C$) dictates that it takes roughly $5\tau$ for the output to settle within 1% of a new target voltage.

The table below calculates real-world performance for an ESP32 outputting a 3.3V logic level at a 5000Hz PWM frequency. The ripple approximation uses the formula $V_{ripple} \approx \frac{V_{peak}}{2 \cdot f_{pwm} \cdot \tau}$.

Resistor (R) Capacitor (C) Cutoff Freq ($f_c$) Time Constant ($\tau$) Settling Time (5$\tau$) Peak-to-Peak Ripple Best Use Case
1kΩ 100nF 1591 Hz 100 µs 0.5 ms ~3.3V (Unfiltered) Audio PWM (requires higher order filter)
1kΩ 1µF 159 Hz 1 ms 5 ms ~330 mV Fast-response motor control
1kΩ 10µF 15.9 Hz 10 ms 50 ms ~33 mV General DC bias / Sensor simulation
10kΩ 10µF 1.59 Hz 100 ms 500 ms ~3.3 mV Ultra-stable precision DC reference

Note: For general-purpose diy projects electronics, the 1kΩ / 10µF combination offers the best balance of low ripple and acceptable response time.

Hardware Build: Parts, Pins, and Wiring

This build targets the ESP32-WROOM-32E DevKit v1. We will generate a sine wave on GPIO 25, filter it, and read it back on GPIO 34 (ADC1_CH6) to verify the output via the Serial Plotter.

Parts List

  • 1x ESP32-WROOM-32E DevKit v1 (30-pin or 38-pin variant)
  • 1x 1kΩ 1/4W metal film resistor (1% tolerance)
  • 1x 10µF 16V multilayer ceramic capacitor (MLCC) or film capacitor (avoid electrolytic due to high ESR and polarity issues)
  • 1x Breadboard and male-to-female jumper wires
  • 1x Digital Multimeter for verification

Pin Mapping Table

ESP32 Pin Function Connects To
GPIO 25 PWM Output (DAC emulation) Resistor Lead 1
Resistor Lead 2 Filter Node / Analog Out Capacitor Lead 1 & GPIO 34
Capacitor Lead 2 Ground Reference ESP32 GND
GPIO 34 ADC Input (Readback) Filter Node (Resistor Lead 2)

Wiring Steps

  1. De-energize: Ensure the ESP32 is unplugged from USB before wiring.
  2. Place Components: Insert the 1kΩ resistor and 10µF capacitor into the breadboard so they share a common node (row) on one side.
  3. Wire PWM: Connect a jumper from ESP32 GPIO 25 to the free leg of the resistor.
  4. Wire Ground: Connect a jumper from the free leg of the capacitor to the ESP32 GND pin.
  5. Wire ADC Readback: Connect a jumper from the shared resistor/capacitor node to ESP32 GPIO 34.
  6. Verify: Use your multimeter in continuity mode to verify the capacitor's ground leg has a solid path to the ESP32 GND pin. Ensure GPIO 25 is not shorted to VCC.

ESP32 Arduino Code: Generating and Reading the Sine Wave

The following code is written for the ESP32 Arduino Core v3.x. Earlier versions used the deprecated ledcSetup() API; v3.x uses the simplified ledcAttach() function. The code generates a 1Hz sine wave using a 256-step lookup table, applies it to the PWM pin, and reads the filtered analog voltage back via the ADC.

/*
 * ESP32 PWM to Analog RC Filter Verification
 * Target: ESP32-WROOM-32E | Core: ESP32 Arduino v3.x
 */

#define PWM_PIN 25      // Output pin (must support LEDC)
#define ADC_PIN 34      // Input pin (ADC1_CH6, input only)
#define PWM_FREQ 5000   // 5kHz PWM frequency
#define PWM_RES 12      // 12-bit resolution (0-4095)
#define SINE_STEPS 256  // Lookup table resolution

// Pre-calculated 12-bit sine wave lookup table (0 to 4095)
uint16_t sineTable[SINE_STEPS];

void setup() {
  Serial.begin(115200);
  delay(500);
  
  // Initialize LEDC (PWM) using Core v3.x API
  if (!ledcAttach(PWM_PIN, PWM_FREQ, PWM_RES)) {
    Serial.println("FATAL: Failed to attach LEDC to GPIO 25");
    while(1) { delay(1000); } // Halt on hardware failure
  }

  // Configure ADC
  analogReadResolution(12); // Match PWM resolution (0-4095)
  analogSetPinAttenuation(ADC_PIN, ADC_11db); // Full 0-3.3V range

  // Generate Sine Lookup Table
  for (int i = 0; i < SINE_STEPS; i++) {
    float angle = (float)i / SINE_STEPS * 2.0 * PI;
    // Map sin(-1 to 1) to 12-bit duty cycle (0 to 4095)
    sineTable[i] = (uint16_t)((sin(angle) + 1.0) * 2047.5);
  }
  
  Serial.println("System Ready. Open Serial Plotter to view waveform.");
}

void loop() {
  static uint8_t stepIndex = 0;
  
  // 1. Write PWM duty cycle from lookup table
  uint16_t targetDuty = sineTable[stepIndex];
  ledcWrite(PWM_PIN, targetDuty);
  
  // 2. Allow RC filter a fraction of a millisecond to settle
  delayMicroseconds(500); 
  
  // 3. Read back the filtered analog voltage via ADC
  uint16_t adcRead = analogRead(ADC_PIN);
  
  // 4. Basic Error Handling / Sanity Check
  // ESP32 ADC is notoriously non-linear near 0V and 3.3V.
  // We flag an error if the ADC saturates unexpectedly.
  if (adcRead >= 4080 && targetDuty < 3800) {
    Serial.println("WARNING: ADC Saturated High. Check for floating ground or overvoltage.");
  } else if (adcRead <= 15 && targetDuty > 300) {
    Serial.println("WARNING: ADC Saturated Low. Check capacitor short to GND.");
  }

  // 5. Output CSV for Arduino Serial Plotter
  Serial.print(targetDuty);
  Serial.print(",");
  Serial.println(adcRead);
  
  // Increment step and wrap around
  stepIndex++;
  if (stepIndex >= SINE_STEPS) {
    stepIndex = 0;
  }
  
  // Delay controls the sine wave frequency. 
  // 256 steps * 4ms = ~1024ms per full cycle (~1Hz)
  delay(4); 
}

Debugging: ADC Non-Linearity and Compile Errors

When working with diy projects electronics on the ESP32, you will inevitably hit hardware quirks. If your build fails, here is how to diagnose the most common issues.

Compile Error: LEDC API Migration

If you copy older code from forums, you will likely encounter this exact compile error:

error: 'ledcSetup' was not declared in this scope

Ranked Causes & Fixes:

  1. Core Version Mismatch: You are using ESP32 Arduino Core v3.0.0 or newer. Espressif deprecated ledcSetup() and ledcAttachPin(). Fix: Replace them with the unified ledcAttach(pin, freq, resolution) as shown in the code above.
  2. Wrong Board Selected: Your IDE is set to a generic ESP8266 or AVR board. Fix: Go to Tools > Board and select ESP32 Dev Module.

Hardware Failure: Output is Noisy or Stuck

If the Serial Plotter shows a flatline or a jagged square wave instead of a smooth sine curve, check these first three things:

  1. Shared Ground Reference: The ESP32's ADC measures voltage relative to its own GND pin. If your multimeter or oscilloscope ground clip is attached to a different ground plane, or if the breadboard's ground rail is broken, the ADC will read garbage. Fix: Verify continuity between the capacitor's ground leg and the ESP32's GND pin.
  2. GPIO Strapping Pin Conflicts: If you changed the PWM pin to GPIO 12, 15, or 2, the ESP32's boot strapping resistors may be pulling the pin HIGH or LOW during startup, fighting your PWM signal. Fix: Stick to safe output pins like GPIO 25, 26, or 27 for analog emulation.
  3. ESP32 ADC Non-Linearity: The ESP32's internal ADC is hardware-limited. It cannot accurately read voltages below ~0.1V or above ~3.1V. If your sine wave peaks look "flattened" at the top and bottom, this is a silicon limitation, not a code bug. Fix: Keep your target analog signals between 0.2V and 3.0V, or use an external I2C ADC.

Extending and Simplifying the Build

Depending on your project requirements, you may need to scale this circuit up or down.

How to Simplify (Skip the Math)

If you do not want to calculate RC values or deal with the ESP32's internal ADC non-linearity, bypass the RC filter entirely. Use an MCP4725 I2C DAC module (~$3 USD). It contains a true 12-bit Digital-to-Analog converter and an internal op-amp buffer. You simply send an I2C command (Wire.write()), and it outputs a flawless, zero-ripple analog voltage. This is the preferred route for precision audio or high-end sensor simulation.

How to Extend (Add an Op-Amp Buffer)

A passive RC filter has a relatively high output impedance (roughly equal to the resistor value, 1kΩ in our build). If you connect a load that draws more than a few milliamps (like a small DC motor or a low-impedance speaker), the voltage will droop severely according to Ohm's Law.

Fix: Add a unity-gain buffer using an op-amp like the MCP6001 or LM358. Connect the RC filter's output to the op-amp's non-inverting input (+), and tie the output directly to the inverting input (-). The op-amp will present a massive input impedance to your RC filter (preventing voltage droop) while providing up to 20-30mA of clean current to your load.

For deeper reading on passive filter design, refer to the RC Filter Tutorial on All About Circuits. For official ESP32 LEDC API documentation and pin strapping rules, consult the Espressif Arduino Core LEDC Documentation.