Introduction: Audio Spectrum Display ASD_V1.0 – ESP32 + 399 WS2812B

You can find several audio spectrum displays / analyzers in the Internet which made LEDs dance to the beat of music. This project was inspired by one of them. It differs quite a bit from well know PLATINUMs - it has fully open source.

If you don't know what it actually do first check YouTube Showcase on Pyrograf channel, then continue reading.

I used ESP32 and it's 2 cores to sample signal, evaluate FFT (Fast Fourier Transform) and finally animate WS2812B LED display. At the moment project is fully functional - it visualize input music from either 3.5 mm audio jack or builtin microphone, but there is room for improvement, so there will be updates in the future.

In this instruction I'l describe:

  • theoretical basis,
  • design process of both electronic and mechanical parts,
  • assembly of both electronic and mechanical parts,
  • programming.

If you need ready to use plans and source code for ESP32 - check out my GitHub Page.

Videos about this project: assembly process, showcase and updates are on my YouTube Channel.

Supplies

Electronics:

  • ESP32,
  • TDA2822,
  • MAX9814,
  • 5 V / 22 A power supply,
  • prototype PCB and manufactured PCBs,
  • 399 WS2812B LEDs,
  • basic electronic components: resistors, capacitors, potentiometers, wires.

Check BOM of main board for detailed information or check the schematic in next steps.

Mechanics:

  • laser-cut acrylics parts,
  • milled acrylic parts.

Tools & others:

  • dremel,
  • osciloscope (highly recommended) to measure audio signal,
  • sandpaper,
  • files,
  • acrylic glue with syringe,
  • ultrasonic cleaner,
  • soldering iron,
  • PCB soldering template,
  • M3, M4 bolts, nuts, washers, rivet nuts.

Step 1: Block Diagram

The electronic part consists of:

  • switching input,
  • analog filter-amplifier,
  • 5 V / 22 A power supply,
  • ESP32,
  • 399 WS2812B LED display (single zig-zag strip),
  • shorter 14 LED strip in base

The power supply delivers 5 V to all devices. Most power is drawn by the display – each LED can draw up to 60 mA of current so it gives up to 24 A in total. In fact mostly it won't exceed 9 A, but power reserve is crucial during playing fast changing effects.

The signal from chosen source is processed in the filter-amplifier block to fit to ESP32 limits.

The ESP32 is used both cores:

  • Core 0 is used to control sampling timer and interrupt, accumulate samples and evaluate FFT,
  • Core 1 is used to calculate animation and drive all LEDs.

Step 2: Signal Source

First thing is to know what kind of signals we are dealing with. In this project there are 2 signal sources:

  • microphone built around MAX9814 IC which not only amplifies raw signal from the microphone, but also controls signal's gain,
  • 3.5 mm audio jack with plug-in switch, to cut off signal from the microphone.

These signals are not similar:

  • signal from microphone has DC offset of 1.22 V and amplitude 0.88 Vrms,
  • signal from audio jack is symmetrical (no DC offset) and has slightly lower amplitude 0.84 Vrms.

So there is needed simple DC removal circuit made out of high-pass filter. Full schematic in the further step.

Step 3: Microphone Filter Prototype - Stage 1

In the datasheet of MAX9814 IC we can read that it's output impedance is 50 Ω - it is low enough to drive most loads.

There is information about capacitive load which refers to stability of builtin amplifier. Capacitive loads can decrease so called margin phase and made amplifier not stable which brings unwanted noise, ringing or even breaking down.

RC Filter

RC filters are used to damp specific frequencies or remove DC or AC parts of the signal. Let's assume that the signal's frequency ranges from 20 Hz to 20 kHz which is angular frequency of 125 rad/s to 125664 rad/s. We want to filter out DC valies (low frequency) so we need high pass filter.

Filtering is based on the fact that so called reactive load like capacitor or inductor shows reactance - resistance for AC signal. It is different for smaller and larger frequecies - see figure.

Cutoff frequency

Connection of resistor and capacitor creates RC filter and can be described by well-known formula for cutoff frequency seen also in datasheet - check photo.

Cutoff frequency is also called 3dB frequency because that is the point on amplitude's plot where those 1-stage filters (both low and high pass) reach damping of 3dB (or where the signal reaches 2^-0.5 of its maximum which means maximum is divided by square root of 2).

Drop of 3dB can be significant in entire range so it is better to shift cutoff frequency a bit lower.

R = 10 kΩ and C = 1 uF were chosen to achieve cutoff frequency of 100 rad/s (16 Hz), thus output resistance never exceed 6 kΩ, and voltage drops at 20 Hz to 0.7 Vrms - see figures.

Step 4: Analog Filter

Signal from stage 1 passes o audio jack plug and then is passed further.

Signal from microphone is mono but signal from jack is stereo, so there is needed a mixing block made out of 2 parallel resistors. Then there is voltage divider lowering voltages to fit the TDA2822's high amplification factor.

Then there is another RC high pass filter to:

  • remove any (potential) DC offset which could be delivered by audio jack (but should not),
  • separate signal to be finally shifted by DC offset to desired value.

DC offset is needed to process amplified signal on IC with not symmetrical power source.

Simulation of entire filter was done in Numpy Python and Colab notebook - check out my github. To see behavior of this system see figures. Potentiometer significantly changes output impedance and voltage.

As you can see in voltage plot there is still quite high dispersion across frequencies especially when voltage is cranked up. If any problems with low frequency (bass) detection occurs then you can try shifting cutoff frequency even lower by decreasing resistance or capacity of RC components.

Step 5: Amplifier Output

In the datasheet of TDA2822 there is typical application for low impedance load. It consists of high pass filter matching low impedance of headphones or speaker and their inductive character (see schematic and impedance comparison). In this project typical application won't work.

Because of 5 V supply, TDA2822 gives max 5 V peak-to-peak voltage. ESP32 requires voltage < 3.3 V so there is needed some way of lowering the voltage. There are 2 solutions:

  • voltage divider,
  • high pass filter with voltage divider as DC offset adder.

The second option was chosen. Final filter consists of 100 uF capacitor and voltage divider made out of two 10 kΩ resistors ensuring cutoff frequency of 12 Hz.

Additional 3.3 V Zener diode is added in parallel to ensure voltage not exceed 3.3 V.

Step 6: Final Schematic

All the previous steps are combined in final schematic. Worth mentioning is 74125 IC quad buffers which shifts signal's voltage from 3.3 V to 5 V. 5 V driving signal is better for WS2812 LEDs. For variant B of those LEDs it is not as important as for regular WS2812. If you want to use other type of programmable LEDs check their datasheet for voltage requirements.

Step 7: LED Strips

To obtain custom LEDs distance and higher heat dissipation custom PCB for LED were prepared. In larga scale it is not worth the effort to solder them all, and it is much easier and not too expensive to use LED strips.

PCB designs are available on GitHub. Those PCBs consists of 7 LED. 3 modules are creating one column of the display. There are 19 columns + 2 modules in the base of the display so there are used 59 modules in total.

Some modules are equipped with capacitor. In the datasheet of WS2812B it is mentioned that all LEDs need decoupling 100nF capacitor so most of PCB has one and some has additional tantal or electrolytic for energy storage needed during playing fast changing animation.

After soldering boards were cleaned using ultrasonic cleaner and IPA.

Step 8: Power Supply

5 V / 22 A DC power supply with on-off switch placed on the side of casing. The display can be connected to power grid using IEC/Euro cable.

Step 9: Casing Design

The casing was inspired by PLATINUM channel on YouTube. All parts were prepared in Corel Draw and are available on my GitHub both in PDF and CDR file formats.

The pixels of this display were milled to ensure mat finish. Rest of the parts were laser-cut and relieved in heating process. Relieving is needed to prevent acrylic from cracking during treating with alcohol based glue.

Step 10: Display Assembly

Each column consists of 3 LED modules. To help solder them equally, the template was prepared. It was designed in Corel Draw based on display's spacer which is used to mount entire strips.

After assembling all 19 columns connect them together by soldering GND and 5 V rails - connecting all solderpads will help distribute energy.

Then connect signal outputs and inputs in zig-zag.

Step 11: Casing Assemby

Remove protective film from smaller parts. Bigger parts like back of casing should be uncovered at the end.

First glue LED holders to front grid. Then glue 3 borders - top, and both sides. Bottom is open to mount aluminium square profiles. Then you can try fitting LEDs, they should fit into holders.

Cut aluminium profiles as marked in drawings - one should be shorted to hide inside casing. Drill holes. Tap bottom holes. Connect profiles from both sides using bolts and nuts (see photos).

Prepare mounting holes using washer-nuts - heat up the rivet nut using soldering iron then press to piece of acrylic. After cooling down glue the acrylic piece with rivet to inner side of the casing. Use small clamps to prevent parts from moving.

Cut supports from spare pieces of acrylic - they should support flexible back of the casing.

After gluing all mounting holes and supports try fitting back of the casing, mark holes using caliper - measure distance of hole then transfer it on the acrylic sheet. Drill holes using dremel tool and try screwing it.

Smart tip - you can use CA glue to fast catch parts, then regular acrylic glue to strengthen bounds. Don't let the glue drip on parts because it melts acrylic and will leave visible spots.

Step 12: Power Supply Mounting

Glue 2 spare mounting holes to the back of casing. Screw power supply to it.

Step 13: Pixels Assembly

This is the most time-consuming part. If you have precisely cut pieces it should be much faster. In my case block of pixels were cut not accurately and I was forced to sand them quite harsh to be able to glue them. Ask your manufacturer how precisely he can cut.

Process is quite simple and til the end of gluing 399 block you will master it! If you feel confident you can try gluing several parts in the same time.

Square would be useful to maintain right angle - you can make one out of spare aluminum profile. After gluing first block focus on maintaining equal spaces between blocks. Glued blocks can be moved a bit but be careful - glue bounds fast. After 5 minutes parts will be bounded permanentny.

You can try powering LEDs to see the results. You will be shocked how cool it looks!

Step 14: LED Grid Fasten

PCBs has holes to screw them to the front acrylic grid. Try getting 1.5 mm screws or find out any other way to fasten those boards. Hot glue is not recommended because PCBs can heat up.

Step 15: Base Assembly

The base is made out of thick acrylic. Screw it to aluminum profile. Stick two single LED boards to narrow holes - they will light sides of the base.

Step 16: Electronic Controller Assembly

ESP32 is attached to custom PCB. This board is only prototype and its project is not included in documentation - use schematic instead to make final version. Proof board should be fine.

Step 17: Programming

Programming finally! You can simply download code from GitHub upload code using Visual Studio Code and Platformio. Code can be extended especially with new light effects.

Main structure

Code consists of 3 parts:

  • sampling in interrupt with timer,
  • FFT processing,
  • animating and driving LEDs.

First 2 parts are executed on Core 0 of ESP32 and last part is executed on Core 1.

The entrance of the code is very neat:

#include <Arduino.h>
#include "presets.h"
#include "effects.h"
#include "ffthsv.h"

effect_handler_t fire_effect_handlers[] = {fire_effect};

void setup() {
    Serial.begin(115200);
    FFTHSV_begin();
    FFTHSV_set_effects(fire_effect_handlers, 1);
    FFTHSV_select_effect(0);
}

void loop() {
    FFTHSV_update();
}

Sampling

Code starts in main.cpp file - there are tasks spread among 2 cores. To sample there is timer function:

void IRAM_ATTR on_timer_sample() {
  portENTER_CRITICAL_ISR(&sampling_timerMux);
  adc_readings_counter++;
  readings_buffer[reading_buffer_index++] = analogRead(A6);
  if(reading_buffer_index >= READING_BUFFER_SIZE)
    reading_buffer_index = 0;
  portEXIT_CRITICAL_ISR(&sampling_timerMux);
}

Sampling buffer is twice long (2x256). Samples are stored in buffer and when indexing reaches the end of the buffer it start from index 0. This way buffer will be continuously updating.

FFT need array of samples so before firing FFT calculation samples are loaded using function:

load_samples(reading_buffer_index, readings_buffer, READING_BUFFER_SIZE);

This function extracts samples stored before current index. If there is not enough samples till 0 index, then rest of them are loaded from the back of array:

void load_samples(int read_index, volatile int* read_buffer, const int buffersize) {
    for (int i = 0; i < SAMPLES_COUNT; i++) {
        vReal[i] = read_buffer[read_index--];
        vImag[i] = 0;
        if(read_index < 0)
            read_index = buffersize-1;
    }
}

Variable read-index is passed by copy so it can bedestroyed in his process.

Fast Fourier Transform (FFT)

There is used Arduino library arduinoFFT and it is packed with custom functions. In analyser.cpp file you can find function:

void calculate_bars() {
    FFT.DCRemoval();
    FFT.Windowing(FFT_WIN_TYP_HAMMING, FFT_FORWARD);
    FFT.Compute(FFT_FORWARD);
    FFT.ComplexToMagnitude();  // wynik w vReal
  
    for (int i = 0; i < BANDS_COUNT; i++)
      magnitude_bands[i] = 0.0;

    for (int i = 1; i < USABLE_SAMPLES_COUNT; i++) {
        if (vReal[i] > NOISE_LIMIT)
            magnitude_bands[bands512[i]] += vReal[i];
    }
    for (int i = 0; i < BANDS_COUNT; i++)
      magnitude_bands[i] *= BAR_HEIGHT_MODIFIER;
}

It uses Hamming windowing function, evaluates FFT and transfer 256 magnitudes into 19 bounds (heights of displays columns). Each bound is related to frequency range. Those ranges are not equal and depends on exponential function which describes which magnitudes should be merged. In other words: 256 magnitudes describing how much specific frequency is in the signal are placed in bins and add together to form more general frequency range.

Bins

Bins are sets of indices which tells which magnitude should be added. Indices are calculated using exponential function because lower frequencies has higher impact on the signal. In other words: the higher frequency the wider range or bandwidth.

To evaluate those bins there is Python function:

def list_bonds(min_freq, max_freq, sampling_rate, samples_count, bands_count)

And entire script returns file containing ready to use array:

int bands[255] = {
0, 0, 0, 1, 2, 3, 4, 4, 5, 5, 5, 6, 6, 7, 7, 7, 7, 7, 8, 8, 8, 8, 9, 9, 9, 9, 9, 9, 10, 10, 10, 10, 
10, 10, 10, 10, 10, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 13, 13, 13, 13, 
13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 
14, 14, 14, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 16, 16, 
16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 
17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 
17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 
18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18};

This array holds all 256 indices, so for example: program iterates over fresh magnitudes array and it should somehow transfer them to 19 bounds. It starts in index 0 and looks at the table (actually it is called Lookup Table) and sees that index 0 should be added to band 0. Indices 1 and 2 also. Then it adds to bound 1, 2, etc.

Physics

Those lets say heights are used to animate LEDs. Changing in height called is first derivative - velocity and changing in velocity acceleration. Using basic physics you can simulate motion and applying gravity you can make bars drop dynamically. You can find this function in ffthsv.cpp file:

void calc_movement(double* thrs, double* pos, double* vel, double grav, int datalen, double dt, double* diffs) {
    for(int i = 0; i < BANDS_COUNT; i++) {
        pos[i] += dt * vel[i];

        if(pos[i] < 0.0) {
            pos[i] = 0.0;
            vel[i] = 0.0;
        }

        if(pos[i] > 21.0) {
            pos[i] = 21.0;
            vel[i] = 0.0;
        }
    }

    for(int i = 0; i < BANDS_COUNT; i++) {
        thrs[i] = constrain(thrs[i], 0.0, 21.0);

        diffs[i] = thrs[i] - pos[i];
        if(diffs[i] >= 0.0) {
            pos[i] = thrs[i];
            vel[i] = 0;
        }
        else{
            vel[i] += -grav * dt - 0.5;
        }
    }
}

Animations

There is very convenient way of implementing custom effects by using template function handler:

typedef void (*effect_handler_t)(const int* bar_heights, const double* bar_thrs, double* energy, CRGB* display_leds, CRGB* base_leds);

It contains all parameters needed to evaluate current frame of animation and save it to buffer so that it will be displayed at the end of update call.

Inside effects.cpp file you can find example function which is then passed as pointer in the main.cpp file.


Step 18: Final Effects and Plans

Animation are played smoothly with 30 Hz refresh rate. Check out all my video on on Youtube channel Pyrograf to see more. I will upload updates of this project so consider subscribing and commenting to help me continue improving this construction. In future I want to add external SPI or I2S ADC for example ADS1256 and WiFi connectivity.

Thank you for reading, I hope it will inspire you as PLATINUM inspired me :)

Despite YouTube and GitHub you can find me on: