Built-in project · ESP32 DevKit V1

Audio spectrum analyzer on an ESP32 DevKit

An INMP441 I2S microphone on an ESP32, a 256-point FFT, and sixteen dancing bars with peak-hold dots on an OLED. Pick a tone, noise or voice and drag its pitch. The whole build, an ESP32 DevKit and 2 more parts, runs here in your browser on the firmware below; open it in the editor to change the wiring or the code and run it again.

Teaching? Assign it to your class in one click.

Advanced About 20 minutes to build and run. Runs in your browser. Free, and no account needed.

The parts retail for about $15.50. On Mokxi they're free.

The circuit runs here once the page has loaded.

What you'll learn

  • Sensors
  • Displays
  • Sound
  • Using the I2S microphone
  • Difficulty: Advanced
  • Time: About 20 minutes
  • Board: ESP32 DevKit V1
  • Parts: 4

How it works

An INMP441 I2S microphone on an ESP32, a 256-point FFT, and sixteen dancing bars with peak-hold dots on an OLED. Pick a tone, noise or voice and drag its pitch.

ESP32 DevKit V1 runs the sketch below. The circuit around it: I2S microphone, INMP441, OLED display, 128x64. Serial.begin opens the serial port at 115200 baud. millis reads the time since power-up, so the sketch can time things without stopping.

Parts list

4 parts, plus the jumper wires. They retail for about $15.50; on Mokxi they're free.

Wiring table

Every connection, pin by pin, read from the circuit itself. Each row joins the pin on the left to the pin on the right, by a jumper wire or a breadboard strip.

Part and pinConnects to
OLED display, 128x64 pin SCLESP32 DevKit V1 pin D22
OLED display, 128x64 pin SDAESP32 DevKit V1 pin D21
I2S microphone, INMP441 pin SCKESP32 DevKit V1 pin D19
I2S microphone, INMP441 pin WSESP32 DevKit V1 pin D18
I2S microphone, INMP441 pin SDESP32 DevKit V1 pin D5
I2S microphone, INMP441 pin VDDESP32 DevKit V1 pin 3V3
I2S microphone, INMP441 pin LRGround
I2S microphone, INMP441 pin GNDGround
OLED display, 128x64 pin GNDGround
OLED display, 128x64 pin VCC3.3 V

The code

The firmware exactly as the editor opens it. Change a line there and press Run: it compiles in the browser.

sketch.ino · ESP32 DevKit V1
// Audio spectrum analyzer: an INMP441 I2S microphone, an FFT and dancing bars.
//
//   INMP441   SCK -> GPIO 19   WS -> GPIO 18   SD -> GPIO 5
//             VDD -> 3V3   GND -> GND   L/R -> GND (left slot)
//   SSD1306   SDA -> GPIO 21   SCL -> GPIO 22   VCC -> 3V3   GND -> GND
//
// # From sound to bars
//
// A microphone gives you a number for the air pressure many thousands of times
// a second. That is a waveform: you can see how LOUD it is, but not what
// notes are in it. The Fast Fourier Transform (FFT) takes a short chunk of the
// waveform and answers a different question: how much of each pitch is in it?
// A pure 1000 Hz tone becomes one tall spike at 1000 Hz. A voice becomes a
// handful of spikes (the pitch and its overtones). Drawing the answer as a row
// of bars, left for bass and right for treble, is the classic spectrum
// analyzer.
//
// # The numbers
//
//   sample rate         16000 per second
//   chunk               256 samples, which is 16 ms of sound
//   frequency per bin   16000 / 256 = 62.5 Hz
//   highest pitch       half the sample rate, 8000 Hz (Nyquist)
//
// A chunk of 256 samples gives 128 bins from 0 to 8000 Hz. That is too many
// for a screen 128 pixels wide, and our ears hear pitch on a log scale (each
// octave is double the frequency), so the sketch folds the bins into 16 bars
// whose widths grow: the left bars hold one bin each, the right bars twenty.
//
// Each bar shows the loudest bin in its band, in decibels below a full-scale
// sine (dBFS). Decibels squash the huge range between a whisper and a shout
// into something a 54 pixel bar can show. Bars jump up at once and drift down
// slowly, and a dot at the top of each one falls even slower, the peak hold
// every hi-fi has.
//
// The INMP441 sends 24-bit samples in 32-bit slots, left aligned, so shifting
// the sample right by 8 gives the number. The first quarter second after power
// up is zeros while the microphone settles, as its data sheet says.
//
// Pick the microphone's sound (tone, noise or voice) and drag its pitch and
// level to watch the bars move.

#include <driver/i2s.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <arduinoFFT.h>
#include <math.h>

#define I2S_PORT I2S_NUM_0
#define I2S_SCK 19
#define I2S_WS 18
#define I2S_SD 5

const int kRate = 16000;
const int kSamples = 256;
const int kBars = 16;

// The first FFT bin of each bar, then one past the last. Bin i is i x 62.5 Hz.
const int kEdges[kBars + 1] = {1, 2, 3, 4, 6, 8, 10, 13, 17, 22, 28, 36, 46, 59, 76, 98, 128};

// The bars span -85 dBFS (the room) up to -25 dBFS (loud and close).
const float kFloorDb = -85.0f;
const float kCeilDb = -25.0f;
const int kTop = 10;     // the first row of the bar area
const int kHeight = 54;  // bar area height in pixels

Adafruit_SSD1306 display(128, 64, &Wire, -1);

int32_t samples[kSamples];
float vReal[kSamples];
float vImag[kSamples];
ArduinoFFT<float> FFT = ArduinoFFT<float>(vReal, vImag, kSamples, kRate);

float level[kBars];  // each bar's height, 0 to kHeight
float peak[kBars];   // each bar's falling dot
unsigned long lastReport = 0;

void setupMicrophone() {
  // Start from all zeros and set what we need, so any field this driver version
  // adds later is simply left at its default.
  i2s_config_t config = {};
  config.mode = i2s_mode_t(I2S_MODE_MASTER | I2S_MODE_RX);
  config.sample_rate = kRate;
  config.bits_per_sample = I2S_BITS_PER_SAMPLE_32BIT;
  config.channel_format = I2S_CHANNEL_FMT_ONLY_LEFT;  // L/R tied to GND
  config.communication_format = I2S_COMM_FORMAT_STAND_I2S;
  config.intr_alloc_flags = ESP_INTR_FLAG_LEVEL1;
  config.dma_buf_count = 4;
  config.dma_buf_len = 256;

  i2s_pin_config_t pins = {};
  pins.mck_io_num = I2S_PIN_NO_CHANGE;
  pins.bck_io_num = I2S_SCK;
  pins.ws_io_num = I2S_WS;
  pins.data_out_num = I2S_PIN_NO_CHANGE;
  pins.data_in_num = I2S_SD;

  i2s_driver_install(I2S_PORT, &config, 0, NULL);
  i2s_set_pin(I2S_PORT, &pins);
  i2s_zero_dma_buffer(I2S_PORT);
}

// One chunk of sound in, one set of bar heights out. Returns the loudest pitch.
float listen() {
  size_t got = 0;
  i2s_read(I2S_PORT, samples, sizeof(samples), &got, portMAX_DELAY);
  int n = got / sizeof(int32_t);
  if (n < kSamples) return 0;

  // The 24-bit sample, scaled so a full-scale sine has amplitude 1.0.
  float mean = 0;
  for (int i = 0; i < kSamples; i++) {
    vReal[i] = (float)(samples[i] >> 8) / 8388607.0f;
    mean += vReal[i];
  }
  mean /= kSamples;
  for (int i = 0; i < kSamples; i++) {
    vReal[i] -= mean;  // take out any steady offset
    vImag[i] = 0;
  }

  // A window fades the chunk in and out so its hard edges do not smear the
  // spectrum, then the transform, then the size of each bin.
  FFT.windowing(FFTWindow::Hann, FFTDirection::Forward);
  FFT.compute(FFTDirection::Forward);
  FFT.complexToMagnitude();
  float pitch = FFT.majorPeak();

  for (int b = 0; b < kBars; b++) {
    float strongest = 0;
    for (int bin = kEdges[b]; bin < kEdges[b + 1]; bin++) {
      if (vReal[bin] > strongest) strongest = vReal[bin];
    }
    // Hann halves a sine's size and a bin holds half of it again: x 4 / N.
    float amplitude = strongest * 4.0f / kSamples;
    float db = amplitude > 1e-9f ? 20.0f * log10f(amplitude) : -180.0f;
    float h = (db - kFloorDb) / (kCeilDb - kFloorDb) * kHeight;
    if (h < 0) h = 0;
    if (h > kHeight) h = kHeight;
    // Up at once, down slowly.
    level[b] = h > level[b] ? h : level[b] * 0.8f + h * 0.2f;
    peak[b] = level[b] > peak[b] ? level[b] : peak[b] - 0.8f;
    if (peak[b] < 0) peak[b] = 0;
  }
  return pitch;
}

void draw(float pitch) {
  display.clearDisplay();
  display.setTextColor(SSD1306_WHITE);
  display.setTextSize(1);
  display.setCursor(0, 0);
  display.print("SPECTRUM");
  display.setCursor(74, 0);
  if (pitch > 60) {
    display.print((int)pitch);
    display.print(" Hz");
  } else {
    display.print("-- Hz");
  }
  for (int b = 0; b < kBars; b++) {
    int x = b * 8;
    int h = (int)level[b];
    if (h > 0) display.fillRect(x, kTop + kHeight - h, 6, h, SSD1306_WHITE);
    int p = (int)peak[b];
    if (p > 1) display.drawFastHLine(x, kTop + kHeight - p - 2, 6, SSD1306_WHITE);
  }
  display.display();
}

void setup() {
  Serial.begin(115200);
  Serial.println("INMP441 spectrum analyzer");
  Wire.begin(21, 22);
  display.begin(SSD1306_SWITCHCAPVCC, 0x3C);
  setupMicrophone();
}

void loop() {
  float pitch = listen();
  draw(pitch);

  if (millis() - lastReport >= 500) {
    lastReport = millis();
    Serial.print("peak ");
    Serial.print(pitch, 0);
    Serial.print(" Hz bars");
    for (int b = 0; b < kBars; b++) {
      Serial.print(' ');
      Serial.print((int)level[b]);
    }
    Serial.println();
  }
}

Code walkthrough

The key functions of the sketch, and what each one does.

setup sketch.ino

Runs once when the board starts. It gets the pins and libraries ready.

void setup() {
  Serial.begin(115200);
  Serial.println("INMP441 spectrum analyzer");
  Wire.begin(21, 22);
  display.begin(SSD1306_SWITCHCAPVCC, 0x3C);
  setupMicrophone();
}

loop sketch.ino

Runs over and over for as long as the board has power. This is where the project does its work.

void loop() {
  float pitch = listen();
  draw(pitch);

  if (millis() - lastReport >= 500) {
    lastReport = millis();
    Serial.print("peak ");
    Serial.print(pitch, 0);
    Serial.print(" Hz bars");
    for (int b = 0; b < kBars; b++) {
      Serial.print(' ');
      Serial.print((int)level[b]);
    }
    Serial.println();
  }
}

Troubleshooting

The usual mistakes with this circuit, and the fix for each.

  • The microphone gives silence or noise. I2S microphone
    Wire SCK, WS, SD and a clock correctly. Tie L/R to GND or VDD to pick the channel, and read the matching channel. Power it at 3.3 V.
  • Data is shifted or the volume is very low. I2S microphone
    The data is 24 bits in a 32-bit frame, so shift it right (often by 8 or more). Set the I2S sample rate and pins in code.
  • The OLED stays blank. OLED display
    Check SDA and SCL are not swapped, and power it with 3.3 V or 5 V as its board allows. Most use address 0x3C, some 0x3D. Call display.begin() with the matching address.
  • Text never shows up even though the code runs. OLED display
    Drawing only writes to a buffer. Call display.display() after drawing to push it out. Also set a text color with setTextColor(WHITE) and clear the buffer between frames.
  • A pin does not work as an input or output, or the board will not boot or upload. ESP32 DevKit V1
    GPIO 34, 35, 36 and 39 are input only with no pull-ups. GPIO 6 to 11 are used by flash, so avoid them. GPIO 0, 2 and 12 are strapping pins and can block boot if held wrong.
  • A 5 V sensor or signal damages the board or reads wrong. ESP32 DevKit V1
    ESP32 pins are 3.3 V and not 5 V tolerant. Use a level shifter or a divider for 5 V outputs. Power 3.3 V sensors from the 3V3 pin.

Change it and keep it

Open it in the editor, change the circuit or the code, and keep your version in a free account.