How to display a spectrogram on a 0.96 inch OLED?

By admin

How to Display a Spectrogram on a 0.96 Inch OLED

To display a spectrogram on a 0.96 inch OLED, you need to combine an audio input capture system with a microcontroller like an ESP32 or Arduino, and then map the frequency domain data onto the 128x64 pixel matrix of a 0.96 inch 128x64 i2c oled display. The core challenge is that a spectrogram visualizes how frequencies change over time—typically with time on the X-axis, frequency on the Y-axis, and amplitude as color or brightness. On a monochrome OLED, you’ll represent amplitude using pixel intensity (on/off) or dithering patterns. The 128x64 resolution means you have 128 columns for time and 64 rows for frequency bins. For a real-time spectrogram, you’ll need to sample audio at a rate of at least 8 kHz (for frequencies up to 4 kHz, since the Nyquist limit is half the sample rate), but many practical implementations use 16 kHz or 22 kHz to capture more detail. The FFT (Fast Fourier Transform) size directly impacts your frequency resolution: with a 64-point FFT, you get 32 frequency bins (since FFT outputs symmetric results), which fits neatly into the 64 rows if you double the resolution via interpolation. With a 128-point FFT, you get 64 bins, perfectly matching the OLED’s height. Each bin covers a frequency range of sample_rate / FFT_size: for 16 kHz and 128-point FFT, each bin spans 125 Hz. So the display shows frequencies from 0 Hz to 8 kHz, with the top row representing the highest frequencies.

Hardware-wise, you’ll need a microphone module with an analog output, like the MAX4466 or MAX9814 electret microphone amplifier, which provides a clean signal with a gain of 25 dB to 125 dB (adjustable). The microphone connects to an ADC (analog-to-digital converter) pin on your microcontroller. For ESP32, the built-in ADC has a 12-bit resolution (0-4095), but it’s noisy—consider using an external ADC like the ADS1115 (16-bit, I2C) for better accuracy. The OLED itself uses I2C communication, with a default address of 0x3C (or 0x3D depending on the module). The I2C bus runs at 400 kHz (fast mode) on most microcontrollers, which is sufficient for updating the display at 30 frames per second (fps) or more. Each frame update requires sending 1024 bytes (128x64 bits = 8192 bits, or 1024 bytes) over I2C. At 400 kHz, a single byte transfer takes about 20 microseconds (including start/stop bits), so 1024 bytes take roughly 20.5 milliseconds. That limits your theoretical max refresh rate to about 48 fps, but in practice, you’ll hit around 30 fps due to processing overhead. For a spectrogram, you don’t need 30 fps—10-15 fps is enough for smooth visual feedback, so you have ample time for FFT computation.

The software pipeline starts with audio sampling. On an ESP32, you can use the I2S peripheral to sample audio from a digital microphone (like the INMP441) at 16 kHz with 16-bit samples. I2S is preferable because it’s synchronous and avoids ADC jitter. If using an analog mic, you’ll sample via ADC at a fixed rate using a timer interrupt. For example, set a timer to trigger every 62.5 microseconds for 16 kHz sampling. Collect 128 samples (for a 128-point FFT) before processing. This takes 8 milliseconds of audio data. Apply a windowing function (like Hamming or Hanning) to reduce spectral leakage—the Hamming window has a sidelobe attenuation of about -43 dB, which is sufficient for a visual display. Then compute the FFT using a library like ArduinoFFT or ESP32’s built-in ESP-DSP library. The FFT output gives you complex numbers; take the magnitude (sqrt(real^2 + imag^2)) for each bin. For 128-point FFT, you get 64 magnitude values (bins 0-63, ignoring the mirror). These magnitudes represent the energy in each frequency band. To map them to the OLED’s 64 rows, you can use a logarithmic scale because human hearing is logarithmic. For example, map the magnitude range (0 to 4095 for 12-bit ADC) to pixel brightness (0 to 255 for grayscale, but on monochrome OLED, you use dithering). A simple approach: normalize magnitudes to 0-63, then for each bin, light up that many pixels from the bottom of the column. But that gives a bar graph, not a spectrogram.

For a true spectrogram, you need to shift the display left by one column each time you get new FFT data. The OLED’s buffer is 1024 bytes (128 columns x 8 pages, since each page is 8 pixels tall). You can implement a circular buffer in memory: keep a 2D array of 128 columns x 64 rows (as bytes, 1024 bytes total). When new data arrives, shift all columns left by one, then write the new column at the rightmost position. This is computationally cheap—just a memmove of 1024 bytes, which takes microseconds on a 240 MHz ESP32. Then, for each of the 64 frequency bins, determine the pixel intensity. Since the OLED is monochrome, you have to use dithering to simulate grayscale. A 2x2 Bayer matrix dithering can give 5 levels of brightness (0 to 4). For each pixel, compare the magnitude value to a threshold. For example, if magnitude is in the top 20% of the range, set the pixel on; if in the next 20%, use a checkerboard pattern, etc. This adds visual depth without needing grayscale hardware. Alternatively, you can use a simpler threshold: if magnitude > average, light up the pixel; otherwise, leave it off. This gives a binary spectrogram, which is still useful for seeing frequency peaks.

Practical implementation details: On an ESP32 with Arduino IDE, you’ll use the Adafruit SSD1306 library for the OLED. Initialize the display with `Adafruit_SSD1306 display(128, 64, &Wire, -1);`. The library provides `drawPixel(x, y, color)` and `display.display()` to push the buffer. For the FFT, use the `arduinoFFT` library. Set up the audio sampling with a timer or I2S. Here’s a rough code flow: in `setup()`, initialize serial, I2C, OLED, and audio input. In `loop()`, sample 128 audio points, apply window, compute FFT, get magnitudes, shift the spectrogram buffer, map magnitudes to pixels, and call `display.display()`. Benchmarking: on an ESP32 at 240 MHz, a 128-point FFT takes about 1.2 milliseconds using the ESP-DSP library (which uses hardware accelerators). The memmove for buffer shift takes about 0.1 milliseconds. The pixel mapping loop (64 iterations) takes about 0.5 milliseconds. So total processing per frame is under 2 milliseconds, leaving plenty of time for sampling (8 milliseconds for 128 samples at 16 kHz). That means you can achieve a theoretical frame rate of 100 fps, but the I2C update limits you to 30 fps. To optimize, you can update the display only every 4th FFT result, giving you 4x time averaging for smoother visuals.

Frequency resolution trade-offs: With a 128-point FFT at 16 kHz, you get 64 bins covering 0-8 kHz, each bin 125 Hz wide. This is good for voice spectrograms (human voice ranges from 85 Hz to 255 Hz for fundamental, but harmonics go up to 8 kHz). For music, you might want higher frequency resolution. Using a 256-point FFT at 16 kHz gives 128 bins (0-8 kHz, 62.5 Hz each), but you can’t display all 128 bins on a 64-row OLED—you’d need to average or decimate. For example, average every 2 bins to get 64 rows, or use a non-linear mapping: more bins for low frequencies (logarithmic). A 256-point FFT also requires 256 samples (16 milliseconds of audio), which increases latency. For real-time applications, keep latency under 50 milliseconds (the threshold for perceptible delay). With 16 ms of audio plus 2 ms processing, you’re well under that. Another option: use a 64-point FFT for 32 bins, then interpolate to 64 rows using linear interpolation (e.g., bin 0 maps to row 0, bin 1 maps to rows 2-3, etc.). This reduces computational load but loses frequency detail.

Power consumption matters for portable projects. The SSD1306 OLED consumes about 20 mA when all pixels are on, but typical spectrogram usage (50% pixels on) draws around 12 mA. The ESP32 in active mode draws 80-100 mA. Total system draw is around 100-120 mA, which a 500 mAh LiPo battery can power for about 4 hours. For lower power, use an Arduino Nano (30 mA) or an ESP32 in deep sleep between samples, but that’s tricky for real-time audio. You can also dim the OLED by using a lower refresh rate (e.g., 5 fps) and reducing the display’s contrast register (set via `display.ssd1306_command(SSD1306_SETCONTRAST)` with values from 0 to 255; default is 127). At 50% contrast, current drops to about 8 mA.

Color vs. monochrome: The 0.96 inch OLED is typically monochrome (white, blue, or yellow). Some variants have dual-color (yellow top 16 pixels, blue bottom 48 pixels), but the standard is single-color. For a spectrogram, monochrome is fine if you use dithering. If you want color, you’d need a 0.96 inch RGB OLED (like the SSD1331, which is 96x64 and costs more). But the I2C version of the 0.96 inch OLED is limited to monochrome because the SSD1306 driver doesn’t support color. For a color spectrogram, you’d need an SPI-based OLED or a TFT display. However, the 128x64 monochrome OLED is popular for its simplicity and low cost (around $5-10). The I2C interface uses only 2 pins (SDA and SCL), making it easy to wire.

Real-world example: A common project is a music visualizer that runs on an ESP32 with a MAX4466 mic. The code samples at 20 kHz, uses 256-point FFT, maps to 64 rows via logarithmic scaling, and updates the display at 15 fps. The result is a scrolling spectrogram that shows bass on the left, treble on the right. Users report that the display is readable up to 10 feet away, and the dithering provides enough contrast to see frequency peaks. For better visual quality, some use a 1.3 inch OLED (128x64, same resolution but larger pixels) or a 0.96 inch OLED with a higher refresh rate (e.g., 60 fps via SPI, but I2C limits to 30 fps). The I2C bus can be pushed to 1 MHz (fast mode plus) on supported microcontrollers, reducing frame update time to 8.2 milliseconds, allowing 60 fps. But the SSD1306’s internal timing may not support that—check the datasheet: the maximum I2C clock is 400 kHz for the SSD1306. So stick with 400 kHz.

Data table for reference:

FFT Size vs. Resolution:

| FFT Size | Sample Rate (Hz) | Frequency Bins | Bin Width (Hz) | Audio Duration (ms) | Latency (ms) |
|----------|------------------|----------------|----------------|---------------------|--------------|
| 64 | 8000 | 32 | 125 | 8 | 10 |
| 64 | 16000 | 32 | 250 | 4 | 6 |
| 128 | 8000 | 64 | 62.5 | 16 | 18 |
| 128 | 16000 | 64 | 125 | 8 | 10 |
| 256 | 16000 | 128 | 62.5 | 16 | 18 |
| 256 | 22000 | 128 | 85.9 | 11.6 | 14 |

For most applications, a 128-point FFT at 16 kHz is a good balance between resolution and latency. The 64 bins map directly to the OLED’s 64 rows without interpolation. If you want to cover the full audible spectrum (20 Hz to 20 kHz), you’d need a sample rate of 40 kHz, which gives you 20 kHz max frequency. With a 128-point FFT at 40 kHz, you get 64 bins covering 0-20 kHz, each bin 312.5 Hz wide. That’s coarse for low frequencies, but it works for a visualizer. Alternatively, use a logarithmic scale: allocate more bins to low frequencies by using a variable FFT size or by post-processing the magnitudes. For example, use a 256-point FFT at 40 kHz (128 bins, 312.5 Hz each), then map bins 0-10 (0-3.125 kHz) to rows 0-20 (using interpolation), and bins 11-127 (3.125-20 kHz) to rows 21-63 (using averaging). This gives better low-frequency detail.

Software libraries: Besides ArduinoFFT, you can use the CMSIS-DSP library for ARM Cortex-M processors (like the ESP32’s Xtensa cores, but CMSIS is for ARM, not Xtensa). For ESP32, the ESP-DSP library is optimized and includes FFT functions with fixed-point arithmetic for speed. The `dsps_fft_f32` function computes a 128-point FFT in about 0.8 milliseconds at 240 MHz. For the OLED, the Adafruit SSD1306 library is standard, but you can use the u8g2 library for more fonts and graphics. u8g2 supports I2C and has a smaller memory footprint if you use the bufferless mode. However, for a spectrogram, you need a full buffer, so u8g2’s full buffer mode (which allocates 1024 bytes) is similar to Adafruit’s. The u8g2 library also supports hardware acceleration for some displays, but the SSD1306 doesn’t have hardware acceleration beyond basic commands.

Common pitfalls: The I2C bus can be affected by long wires. Keep the wires between the OLED and microcontroller under 20 cm to avoid signal degradation. Use 4.7 kΩ pull-up resistors on SDA and SCL lines (some modules have them built-in). The ESP32’s I2C pins are GPIO 21 (SDA) and GPIO 22 (SCL) by default. For audio input, the microphone’s output should be biased to 1.25V (half of 2.5V for a 5V system) to center the ADC range. The MAX4466 module has a built-in bias, but the output is AC-coupled, so you need to add a DC offset in software or hardware. In code, you can subtract the DC offset by averaging the samples over a few seconds. Another issue: the FFT output magnitudes vary with input volume. You need automatic gain control (AGC) to keep the display from being too dim or too bright. Implement a running average of the peak magnitude over the last 10 frames, and scale the pixel thresholds accordingly. For example, if the peak magnitude is 1000, map 0-1000 to 0-63 rows. If the peak drops to 500, rescale. This gives consistent visual output regardless of input volume.

For a more advanced project, you can add a menu system to switch between spectrogram modes: linear vs. logarithmic, peak hold vs. average, and different color schemes (if using a color display). On the 0.96 inch OLED, you can display text labels for frequency bands using the 5x7 font (8x8 pixels per character), which fits about 16 characters per line. For example, show “500 Hz” at the bottom and “8 kHz” at the top. But this reduces the available pixel area for the spectrogram. A compromise: use the top 8 rows for text and the bottom 56 rows for the spectrogram. That gives you 56 frequency bins, which is still good. Or use the bottom 8 rows for a scrolling text display of the dominant frequency. The u8g2 library supports rotated text, so you can put vertical labels on the left side.

Testing and calibration: Use a frequency generator app on your phone to produce sine waves at known frequencies. Play a 1 kHz tone and verify that the spectrogram shows a bright line at the corresponding row. For a 128-point FFT at 16 kHz, bin 8 (since 1 kHz / 125 Hz = 8) should be lit. If it’s off, check your FFT implementation—ensure you’re using the correct bin index (bin 0 is DC, bin 1 is the first frequency bin). Also, the OLED’s pixel orientation matters: the SSD1306’s default mapping sets the origin at the top-left, with x increasing to the right and y increasing downward. For a spectrogram, you typically want low frequencies at the bottom, so you need to invert the Y-axis: map bin 0 to row 63, bin 63 to row 0. Do this by subtracting the row index from 63. Or use the `setRotation()` function in the Adafruit library to rotate the display 180 degrees, but that also flips the X-axis, which may not be desired.

Performance optimization: If you’re using an Arduino Uno (16 MHz, 2 KB RAM), a