Project Overview & Difficulty Rating
Frequency modulation (FM) is the backbone of everything from analog radio broadcasts to modern telemetry and synthesizers. While you can generate FM using purely analog circuits (like a 555 timer and a varactor diode), a digital approach offers vastly superior stability and precision. In this frequency modulation project, we use an ESP32 microcontroller to compute the modulating signal in software, and an Analog Devices AD9833 Direct Digital Synthesis (DDS) chip to generate the high-frequency carrier.
This build targets the ESP32-DevKitC V4 (ESP32-WROOM-32E). We intentionally avoid RF transmission to stay strictly within FCC/CE regulatory boundaries for unlicensed hobbyists; instead, this project generates a wired, baseband FM signal perfect for driving audio amplifiers, testing spectrum analyzers, or feeding into a software-defined radio (SDR) via a direct wire.
Hardware BOM & Pin Mapping
Before writing a single line of code, verify your exact board variant. The ESP32-WROOM-32E has specific ADC1 pins that do not conflict with the internal WiFi/Bluetooth flash routing. Do not use ADC2 (GPIO 25-27) for the potentiometer if you plan to enable WiFi later.
| Component | Exact Variant / Spec | Est. Cost |
|---|---|---|
| Microcontroller | ESP32-DevKitC V4 (ESP32-WROOM-32E, 30-pin) | $6.00 |
| DDS Module | AD9833 Breakout with 25 MHz SMD Crystal | $8.50 |
| Modulation Input | 10kΩ Linear Taper Trimpot (Bourns 3386P) | $1.20 |
| Wiring | 22 AWG solid core jumper wires | $2.00 |
MCLK_FREQ constant in the code.
ESP32 to AD9833 Pin Mapping
| ESP32-DevKitC V4 Pin | AD9833 Module Pin | Function |
|---|---|---|
| 3V3 | VCC | Power (AD9833 is strictly 3.3V logic) |
| GND | GND | Common Ground |
| GPIO 18 (VSPI SCK) | CLK | SPI Clock |
| GPIO 23 (VSPI MOSI) | DATA | SPI Master Out Slave In |
| GPIO 5 (VSPI CS) | FSYNC | SPI Chip Select (Active Low) |
| GPIO 34 (ADC1_CH6) | Wiper (Trimpot) | Modulation Depth Control |
The Math: How Software-Defined FM Works
In FM, the instantaneous frequency of the carrier wave is varied in proportion to the modulating signal. The governing equation is:
f(t) = f_c + Δf · m(t)
Where f_c is the center carrier frequency, Δf is the peak frequency deviation, and m(t) is the modulating signal (normalized between -1 and 1). According to All About Circuits' FM theory guide, the modulation index (β) is defined as Δf / f_m, which dictates the bandwidth of the resulting signal via Carson's Rule.
The AD9833 doesn't accept Hertz directly. It uses a 28-bit phase accumulator. To convert your desired Hertz output into the register value the chip understands, use this formula from the Analog Devices AD9833 Datasheet:
FreqReg = (f_out × 2^28) / f_MCLK
For a 25 MHz master clock (f_MCLK), the resolution is roughly 0.093 Hz per LSB. To achieve FM, the ESP32 must continuously recalculate FreqReg based on the potentiometer's ADC reading and push those updates to the AD9833 via SPI.
Complete ESP32 Firmware (C++)
This code uses raw SPI transactions rather than a third-party library to eliminate dependency rot and give you exact control over the SPI Mode 2 timing required by the AD9833. It targets the Arduino IDE with the official Espressif ESP32 core installed.
#include <SPI.h>
#include <driver/adc.h>
// --- PIN DEFINITIONS ---
#define FSYNC_PIN 5
#define POT_PIN 34
// --- AD9833 CONFIG ---
#define MCLK_FREQ 25000000UL // 25 MHz Crystal
#define CARRIER_FREQ 100000 // 100 kHz Center Carrier
#define MAX_DEVIATION 20000 // +/- 20 kHz Max Deviation
#define MODULATION_RATE 100 // 100 Hz Sine Modulation Rate
// AD9833 Control Register Bits
#define B28 (1 << 13)
#define HLB (1 << 12)
#define FSEL (1 << 11)
#define PSEL (1 << 10)
#define RESET_BIT (1 << 8)
#define SLEEP1 (1 << 7)
#define SLEEP12 (1 << 6)
// Precomputed Sine Lookup Table (256 steps)
int16_t sine_lut[256];
uint32_t carrier_reg;
float freq_resolution;
void writeAD9833(uint16_t data) {
SPI.beginTransaction(SPISettings(8000000, MSBFIRST, SPI_MODE2));
digitalWrite(FSYNC_PIN, LOW);
SPI.transfer16(data);
digitalWrite(FSYNC_PIN, HIGH);
SPI.endTransaction();
}
void setup() {
Serial.begin(115200);
pinMode(FSYNC_PIN, OUTPUT);
digitalWrite(FSYNC_PIN, HIGH);
// Initialize ADC1 for Potentiometer
analogReadResolution(12);
analogSetAttenuation(ADC_0db); // 0-1.1V range for precise trimpot reading
SPI.begin();
// Calculate Frequency Resolution: 2^28 / MCLK
freq_resolution = pow(2, 28) / (float)MCLK_FREQ;
carrier_reg = (uint32_t)(CARRIER_FREQ * freq_resolution);
// Populate Sine LUT to avoid slow floating-point math in the main loop
for (int i = 0; i < 256; i++) {
sine_lut[i] = (int16_t)(sin(2.0 * PI * i / 256.0) * 32767);
}
// AD9833 Reset Sequence
writeAD9833(B28 | RESET_BIT); // Reset, 28-bit load mode
// Load Carrier Frequency into REG0
uint16_t lsb = (carrier_reg & 0x3FFF) | 0x4000; // DB15=0, DB14=1 (Freq Reg 0)
uint16_t msb = ((carrier_reg >> 14) & 0x3FFF) | 0x4000;
writeAD9833(lsb);
writeAD9833(msb);
writeAD9833(B28); // Exit Reset, Output Sine Wave
Serial.println("AD9833 Initialized. Modulating...");
}
void loop() {
static uint32_t last_update = 0;
static uint8_t lut_index = 0;
// Modulation update timer (approx 10kHz update rate for smooth FM)
if (micros() - last_update >= 100) {
last_update = micros();
// Read Potentiometer (0-4095) and map to Deviation (0 to MAX_DEVIATION)
uint16_t pot_val = analogRead(POT_PIN);
uint32_t deviation_hz = map(pot_val, 0, 4095, 0, MAX_DEVIATION);
// Calculate current deviation register offset
uint32_t dev_reg = (uint32_t)(deviation_hz * freq_resolution);
// Get sine value (-32768 to 32767) and scale to deviation
int32_t current_offset = (int32_t)((sine_lut[lut_index] * (int32_t)dev_reg) >> 15);
uint32_t instant_reg = carrier_reg + current_offset;
// Push to AD9833 (Fast 14-bit load to REG0)
uint16_t lsb = (instant_reg & 0x3FFF) | 0x4000;
uint16_t msb = ((instant_reg >> 14) & 0x3FFF) | 0x4000;
writeAD9833(lsb);
writeAD9833(msb);
// Increment LUT index based on modulation rate
lut_index += (MODULATION_RATE * 256) / 10000;
// Feed the watchdog to prevent resets during high-speed SPI loops
yield();
}
}
Debugging: First Three Things to Check
Embedded DSP projects often fail silently or throw cryptic RTOS errors. If your oscilloscope shows a flatline or a static frequency, check these three things first:
- SPI Mode and FSYNC Logic: The AD9833 requires SPI Mode 2 (CPOL=1, CPHA=0). If your logic analyzer shows data shifting on the wrong clock edge, the chip will ignore the payload. Furthermore, FSYNC must idle HIGH. If your breadboard has a loose ground, FSYNC might float, causing the chip to latch garbage data.
- Task Watchdog Triggering: If your serial monitor spits out
E (4512) task_wdt: Task watchdog got triggered. The following tasks did not reset the watchdog in time:, yourloop()is executing SPI transfers so fast that the ESP32's FreeRTOS IDLE task is being starved. Theyield()command at the end of the timing block in the code above prevents this. If you remove the 100µs delay, the watchdog will bite. - Crystal Frequency Mismatch: If your output frequency is exactly 48% or 96% of what you commanded, your module has a 12 MHz or 24 MHz crystal instead of the 25 MHz assumed in
MCLK_FREQ. Measure the crystal with a frequency counter or read the SMD code on the component.
Extending and Simplifying the Build
Depending on your end goal, you might want to alter the complexity of this circuit.
How to Simplify
If you don't have an oscilloscope to verify the sine wave modulation and just want to test the SPI bus, replace the sine LUT logic with a simple square wave. Toggle a boolean flag every 5 milliseconds to snap the frequency between f_c - Δf and f_c + Δf. This eliminates the LUT and math, allowing you to verify basic register writes.
How to Extend
To turn this from a test-bench generator into a voice-modulated telemetry beacon, replace the 10kΩ trimpot with an INMP441 I2S MEMS Microphone. You will need to use the ESP-IDF I2S driver to read the microphone into a DMA buffer at 44.1 kHz, apply a digital low-pass filter, and map the audio amplitude to the AD9833 deviation register. Note that pushing 44,100 SPI updates per second will max out the ESP32's SPI bus; you will need to implement the ESP32 Hardware Timer API to handle the SPI writes in a dedicated core 1 task.
Frequently Asked Questions
How do I calculate frequency resolution for my frequency modulation project?
The frequency resolution is dictated entirely by the master clock (MCLK) feeding the AD9833 and its 28-bit accumulator. The formula is MCLK / 2^28. For a 25 MHz clock, the resolution is 25,000,000 / 268,435,456 = 0.09313 Hz. This means every time you increment the frequency register by 1, the output shifts by roughly 0.09 Hz. This sub-Hertz resolution is what makes DDS vastly superior to analog VCOs for precision telemetry.
Why is my AD9833 outputting a square wave instead of a sine wave?
The AD9833 has internal control bits that route the output either through the on-chip ROM (which generates the sine wave) or directly from the MSB of the phase accumulator (which generates a square wave). If you are seeing a square wave, check your initialization sequence. Specifically, ensure the MODE bit (Bit 1 in the control register) is set to 0. In the provided code, the B28 constant handles this implicitly during the reset sequence, but if you manually write to the control register later, accidentally setting Bit 1 will bypass the sine ROM.
Can I use this frequency modulation project to transmit FM radio over the air?
Not directly, and you shouldn't try without proper licensing. The AD9833 outputs a baseband signal (up to 12.5 MHz with a 25 MHz clock). It does not generate VHF RF signals (88-108 MHz) natively. While you could feed this output into an RF mixer or a PLL multiplier to reach the FM broadcast band, transmitting on those frequencies without a license violates FCC Part 15 (in the US) and equivalent Ofcom/CEPT rules globally. Keep this project wired for bench testing, audio synthesis, or closed-loop telemetry.






