Clayton BorgesDESENVOLVEDOR
← ← All Projects

ESP32 Digital Synthesizer

2024
Embedded Hardware / Digital Audio Synthesizer

Polyphonic step sequencer synthesizer on ESP32. Subtractive synthesis pipeline: SAW2048 wavetable → resonant lowpass filter → ADSR envelope → soft clipping → DAC. Tick-based 8-step sequencer with swing, randomization, and dual CD4051 multiplexers for 8 analog pots + 5 buttons. Modular C++ firmware with AudioEngine, Sequencer, Controls, and UI layers.

Overview

The ESP32 Synthesizer is a hardware digital audio synthesizer built on an ESP32 microcontroller. It implements a complete subtractive synthesis signal chain — wavetable oscillator, resonant lowpass filter, ADSR envelope, and soft clipping — driven by an 8-step tick-based sequencer with swing timing and per-step randomization. The instrument is controlled by 8 analog potentiometers (cutoff, resonance, ADSR, swing) read via CD4051 multiplexers, displayed on both an OLED screen and a TM1638 LED/7-segment module. The firmware is refactored into modular C++ headers for AudioEngine, Sequencer, Controls, and UI layers.

Problem

Commercial synthesizers in the accessible price range offer limited programmability and no hardware customization. Building a synthesizer from microcontroller primitives requires solving three simultaneous constraints: deterministic audio timing (Mozzi's 16.384kHz audio rate cannot be disrupted by control logic), noise-free analog input (8 pots on a shared ADC bus with settling time issues), and real-time display updates that don't steal CPU from audio rendering.

Goal

Build a playable hardware synthesizer that: (1) implements subtractive synthesis with real-time filter and envelope control; (2) runs a swing-capable 8-step sequencer with deterministic timing; (3) reads 8 analog pots + 5 buttons via multiplexers without ADC contention; (4) displays real-time parameter state on dual displays; (5) uses modular firmware architecture for maintainability.

My Role
  • ◈Solo embedded developer — hardware design, firmware architecture, synthesis algorithm, UI
Stack
C++ESP32ArduinoMozziCD4051 MUXTM1638OLED I2C
Technical Decisions
  1. 01Mozzi library with SAW2048 wavetable — Mozzi provides a pre-computed 2048-sample 8-bit sawtooth wavetable that the oscillator plays back at any frequency via table interpolation. This avoids real-time waveform computation (which would require trigonometric functions at 16.384kHz — unachievable on ESP32). Mozzi's control-rate/audio-rate split (128Hz/16384Hz) enforces a clean architecture: all parameter updates happen at control rate, audio synthesis at audio rate.
  2. 02Tick-based sequencer timing — instead of millis()-based step transitions (which drift under load), the sequencer counts Mozzi control ticks: ticksPerStep = (CONTROL_RATE × 60) / BPM. Each tick is exactly 1/128s. Swing is applied by adjusting odd step counts: even steps get baseTicksPerStep + swingOffsetTicks, odd steps get baseTicksPerStep − swingOffsetTicks. This is mathematically equivalent to traditional hardware sequencer swing with no timing jitter.
  3. 03Round-robin CD4051 multiplexer scanning — a single CD4051 8-channel analog multiplexer connects 8 potentiometers to one ESP32 ADC pin. The control loop reads one channel per tick (round-robin), cycling through all 8 pots over 8 ticks. This eliminates ADC settling time problems that occur when switching channels rapidly, and ensures no two pots share an ADC read in the same tick.
  4. 04IIR smoothing on all analog inputs — raw ADC reads are noisy (±5-15 LSB). Rather than a ring-buffer moving average, each pot uses a single-instruction IIR: new = old + (raw − old) >> 3. This is equivalent to a 1-pole IIR lowpass at fc ≈ 128Hz/16 = 8Hz — sufficient to eliminate audio-rate noise from pot readings without introducing noticeable lag.
  5. 05Soft clipping to prevent digital saturation — the final output stage applies asymmetric soft clipping: if (x > 30000) x = 30000 + (x − 30000) / 4; if (x < -30000) x = -30000 + (x + 30000) / 4. This gently rolls off peaks above ±30000 (out of ±32767 int16 range) rather than hard-truncating, which would introduce harsh aliasing artifacts audible as digital distortion.
  6. 06Modular C++ firmware headers — the original firmware was a monolithic .ino file. Refactoring into AudioEngine.h, Sequencer.h, Controls.h, Events.h, UI.h, and Types.h enables testing the sequencer's tick logic independently of Mozzi, isolates the MUX scanning code from synthesis, and makes future features (MIDI, multiple oscillators, scale quantization) addable without touching unrelated code.
What I Learned
  • →Tick-based timing is the correct architecture for embedded sequencers — an early millis()-based version had timing drift that accumulated over minutes, making the sequencer gradually go out of sync with external audio. Switching to Mozzi control ticks (which are hardware-interrupt driven) eliminated drift completely.
  • →ADC multiplexer settling time is a real problem — the first hardware revision read all 8 pots in rapid succession in a single control tick. The ADC couldn't settle between channel switches, producing cross-talk where rotating pot 3 affected pot 4's reading. Round-robin (one pot per tick) gave the ADC 8 full ticks (~62ms) to settle between readings of the same channel.
  • →IIR smoothing is the right tool for analog noise, not moving averages — a 4-sample moving average added 4 ticks of lag (~31ms) to every pot movement, making the cutoff feel sluggish during live playing. The IIR approach eliminates the lag feeling while still filtering tick-to-tick noise.
  • →Modular firmware unlocks testability — the original monolithic .ino couldn't be unit tested because Mozzi's audio interrupt would run during test execution. Extracting the Sequencer into a pure C++ header with no Mozzi dependencies allowed running sequencer timing tests on a host machine without hardware.
Status

Built — playable hardware instrument