Most beginners think Pulse Width Modulation (PWM) is strictly for dimming LEDs or driving servo motors. But in advanced do it yourself electronics projects, PWM is the foundational mechanism for synthesizing true analog voltages. The ESP32-WROOM-32 features a built-in 8-bit Digital-to-Analog Converter (DAC), but it is notoriously noisy, limited to a 2.5V swing, and restricted to GPIO 25 and 26. By combining a high-resolution 12-bit PWM signal with a passive RC low-pass filter, we can engineer a cleaner, highly flexible DAC that outputs a precise 0-3.3V analog waveform.

This guide bridges the gap between abstract circuit theory and practical embedded firmware. We will calculate the exact RC cutoff frequency, wire the hardware, write modern ESP32 Arduino Core 3.x firmware, and debug the most common compilation errors that trip up makers upgrading their toolchains in 2026.

The Theory: Why PWM Needs an RC Low-Pass Filter

PWM outputs a digital square wave that rapidly switches between 0V and 3.3V. The duty cycle (the percentage of time the signal is HIGH) determines the average voltage. If you output a 50% duty cycle, a multimeter will read roughly 1.65V. However, an oscilloscope will show a harsh 3.3V-to-0V square wave. To convert this into a steady DC voltage or a smooth analog waveform (like a sine wave), we must strip away the high-frequency switching harmonics.

We do this using a first-order passive RC (Resistor-Capacitor) low-pass filter. The theory hinges on the cutoff frequency ($f_c$), the point at which the filter attenuates the signal by -3dB (roughly 70.7% of its original amplitude). The formula is:

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

For our build, we use a $1\text{k}\Omega$ resistor and a $100\text{nF}$ ($0.1\mu\text{F}$) capacitor. Plugging in the numbers:

  • $f_c = \frac{1}{2 \cdot \pi \cdot 1000 \cdot 0.0000001} \approx 1591\text{ Hz}$

Our PWM frequency will be set to $5000\text{ Hz}$ ($5\text{ kHz}$). Because $5\text{ kHz}$ is well above our $1591\text{ Hz}$ cutoff frequency, the fundamental PWM switching frequency is heavily attenuated, leaving behind the smooth, averaged DC envelope. The trade-off in RC filter design is ripple voltage versus response time. A larger capacitor reduces ripple but makes the output sluggish when changing voltages. Our chosen values yield a ripple of less than 15mV at 50% duty cycle, which is more than adequate for hobbyist control loops and audio synthesis.

Project Spec Sheet & Parts List

Precision in component selection matters. Do not substitute the ceramic capacitor with an electrolytic one for this specific high-frequency filtering task; electrolytics have high Equivalent Series Resistance (ESR) and poor high-frequency response.

ComponentSpecification / VariantEst. Cost (2026)Notes
MicrocontrollerESP32-WROOM-32 DevKit V1 (30-pin variant)$5.50Target board for pin mapping. 38-pin variants shift GPIO locations.
Resistor$1\text{k}\Omega$ 1/4W Metal Film (1% tolerance)$0.02Metal film reduces thermal noise vs carbon composition.
Capacitor$100\text{nF}$ ($0.1\mu\text{F}$) X7R Ceramic (50V)$0.05X7R dielectric provides stable capacitance across voltage bias.
Prototyping830-point solderless breadboard & 22 AWG jumpers$8.00Keep jumper lengths under 3 inches to prevent parasitic inductance.

Wiring & Pin Mapping

Safety & Hardware Note: While this project operates at safe 3.3V DC levels, always double-check your GPIO assignments. Feeding 5V into GPIO 34 (an input-only pin on the ESP32) will permanently destroy the internal ADC multiplexer.
  1. PWM Output: Connect a jumper from GPIO 25 to one end of the $1\text{k}\Omega$ resistor.
  2. Filter Junction: Connect the other end of the resistor to the positive leg of the $100\text{nF}$ capacitor. This junction is your analog output.
  3. Ground Reference: Connect the negative leg of the capacitor to the ESP32 GND pin.
  4. ADC Feedback: Connect a jumper from the Filter Junction (the R-C intersection) to GPIO 34 so the ESP32 can read back its own generated voltage.
ESP32 Pin (30-pin Board)FunctionDirectionConnected To
GPIO 25PWM GeneratorOutput$1\text{k}\Omega$ Resistor (Input side)
GPIO 34ADC VerificationInput (ADC1_CH6)R-C Filter Junction
GNDCommon GroundN/A$100\text{nF}$ Capacitor (Ground side)

Complete ESP32 Arduino Core 3.x Code

The ESP32 Arduino ecosystem underwent a massive API overhaul in Core 3.0. The legacy LEDC functions were deprecated and removed. The code below targets the ESP32-WROOM-32 DevKit V1 (30-pin) and uses the modern, hardware-agnostic ledcAttach() API standard in 2026.

#include <Arduino.h>

// Pin definitions for ESP32-WROOM-32 DevKit V1 (30-pin)
const int PWM_OUT_PIN = 25;  // Generates the PWM signal
const int ADC_IN_PIN = 34;   // Reads the filtered analog voltage

// PWM Configuration
const uint32_t PWM_FREQ = 5000;      // 5 kHz PWM frequency
const uint8_t PWM_RESOLUTION = 12;   // 12-bit resolution (0-4095)

// Pre-calculated 64-step sine wave lookup table (scaled 0-4095)
const uint16_t SINE_TABLE[64] = {
  2048, 2249, 2447, 2642, 2831, 3013, 3185, 3346,
  3495, 3630, 3750, 3853, 3939, 4007, 4056, 4086,
  4095, 4086, 4056, 4007, 3939, 3853, 3750, 3630,
  3495, 3346, 3185, 3013, 2831, 2642, 2447, 2249,
  2048, 1847, 1649, 1454, 1265, 1083,  911,  750,
   601,  466,  346,  243,  157,   89,   40,   10,
    0,   10,   40,   89,  157,  243,  346,  466,
   601,  750,  911, 1083, 1265, 1454, 1649, 1847
};

void setup() {
  Serial.begin(115200);
  delay(1000); // Allow serial monitor to connect

  // Modern ESP32 Arduino Core 3.x API for PWM setup
  if (!ledcAttach(PWM_OUT_PIN, PWM_FREQ, PWM_RESOLUTION)) {
    Serial.println("FATAL: Failed to attach LEDC PWM to GPIO 25. Check board variant.");
    while(1) { delay(1000); } // Halt execution
  }

  // Configure ADC for verification
  analogReadResolution(12); // Match 12-bit PWM resolution
  
  Serial.println("ESP32 12-bit RC DAC initialized. Generating 50Hz sine wave.");
}

void loop() {
  static uint8_t step = 0;
  static uint32_t lastUpdate = 0;
  
  // 50Hz sine wave = 20ms period. 20ms / 64 steps = 312.5us per step
  if (micros() - lastUpdate >= 312) {
    lastUpdate = micros();
    
    // Output the PWM duty cycle
    uint16_t duty = SINE_TABLE[step];
    ledcWrite(PWM_OUT_PIN, duty);
    
    // Read back the filtered analog voltage in millivolts
    uint32_t mV = analogReadMilliVolts(ADC_IN_PIN);
    
    // Print every 8th step to avoid flooding the serial buffer
    if (step % 8 == 0) {
      Serial.printf("Target Duty: %4d | Measured ADC: %4lu mV\n", duty, mV);
    }
    
    step = (step + 1) % 64;
  }
}

Debugging: 'ledcSetup' was not declared in this scope

If you are copying code from older tutorials, you will almost certainly hit this compilation error when using the current Arduino IDE:

Exact Error String:
error: 'ledcSetup' was not declared in this scope
error: 'ledcAttachPin' was not declared in this scope

Ranked Causes & Fixes:

  1. ESP32 Core Version Mismatch (Most Likely): In Arduino ESP32 Core 3.0+, Espressif unified the LEDC API. The old channel-based ledcSetup(channel, freq, res) and ledcAttachPin(pin, channel) were entirely removed. Fix: Use the modern ledcAttach(pin, freq, resolution) syntax provided in the code block above.
  2. Incorrect Board Manager URL: You might be pulling an outdated or forked ESP32 hardware package. Fix: Open Boards Manager, search for 'esp32' by Espressif Systems, and ensure you are on version 3.x.x.
  3. Missing Header Inclusion: While Arduino.h usually covers it, some bare-metal ESP-IDF frameworks require explicit inclusion. Fix: Add #include <esp32-hal-ledc.h> at the top of your sketch.

The First Three Things to Check When Hardware Fails

If the code compiles but your oscilloscope or multimeter reads 0V at the filter junction:

  1. Verify GPIO 25 Output: Disconnect the resistor. Upload a simple digitalWrite(25, HIGH) blink sketch. Measure GPIO 25 to GND. If it is not 3.3V, your breadboard rail is dead or the ESP32 pin is fried.
  2. Check Capacitor Seating: Ceramic capacitors frequently fail to make contact in worn breadboards. Use a multimeter in continuity mode to verify the connection from the resistor lead to the capacitor lead.
  3. Confirm ADC Attenuation: If the PWM works but the serial monitor reads erratic ADC values, ensure you are using analogReadMilliVolts(). Standard analogRead() on the ESP32 is highly non-linear near the 3.3V rail without proper attenuation mapping.

Extending and Simplifying the Build

Not every project requires 12-bit precision. Here is how to adapt this circuit to your specific constraints:

  • Simplify (The Internal DAC Route): If you only need 8-bit resolution (0-255) and your load is high-impedance (like an op-amp input), bypass the RC filter entirely. Use the ESP32's internal DAC on GPIO 25 via dacWrite(25, value). This saves two components and eliminates PWM ripple, though you sacrifice the upper 0.8V of the 3.3V rail.
  • Extend (The Op-Amp Buffer): An RC filter has a high output impedance (roughly equal to the resistor value, $1\text{k}\Omega$). If you connect a low-impedance load (like an 8-ohm speaker or a 100-ohm motor controller input), the load will form a voltage divider with your resistor, collapsing the voltage. Fix: Add an LM358 dual op-amp configured as a unity-gain voltage buffer between the RC junction and your load. The op-amp presents near-infinite input impedance to the filter and can source up to 30mA to the load.

FAQ: Common Questions on Do It Yourself Electronics Projects

What are the best do it yourself electronics projects for learning analog theory?

Projects that force you to bridge the digital-to-analog divide are the most educational. Building a function generator using PWM and RC filters (like this one), designing a constant-current LED driver using an op-amp and a shunt resistor, or building a temperature-compensated crystal oscillator (TCXO) using a thermistor network. These projects require you to calculate time constants, understand impedance matching, and deal with real-world parasitic effects that simulators often ignore.

How do I power do it yourself electronics projects without introducing noise?

Switching buck converters (like the ubiquitous LM2596 modules) introduce massive high-frequency switching noise into your ground plane, which will couple directly into your analog RC filters. For precision analog projects, use a linear regulator (like an L7805 or AMS1117-3.3) to step down your supply voltage. If you must use a switching supply for efficiency, follow it with a low-dropout (LDO) linear regulator and add a ferrite bead on the analog VCC rail to choke out high-frequency hash.

Why do my do it yourself electronics projects fail when adding high-current loads?

This is almost always a ground bounce or brownout issue. When a high-current load (like a motor or relay) switches on, it draws a sudden surge of current. The parasitic inductance and resistance of your breadboard traces and jumper wires cause a momentary voltage drop ($V = L \frac{di}{dt}$). This pulls the microcontroller's ground reference above 0V, causing the CPU to reset or the ADC to return garbage data. Always use star grounding for mixed-signal projects, and physically separate high-current ground returns from sensitive analog ground paths.