Mirko Pavleski
Published © GPL3+

DIY ESP32 Audio Spectrum Analyzer

The CrowPanel 3.5" ESP32 proves to be an incredibly versatile platform for high-speed audio visualization.

BeginnerFull instructions provided2 hours254
DIY ESP32 Audio Spectrum Analyzer

Things used in this project

Hardware components

CrowPanel 3.5"-HMI ESP32 Display
×2

Software apps and online services

Arduino IDE
Arduino IDE

Hand tools and fabrication machines

Soldering iron (generic)
Soldering iron (generic)
Solder Wire, Lead Free
Solder Wire, Lead Free

Story

Read more

Schematics

Schematic

...

Code

Code 24 bars

C/C++
...
// by mircemk, July 2026

// ============================================================
//  Audio Spectrum Analyzer V2.1 TUNED  (Single FFT)
//  Hardware : Elecrow CrowPanel ESP32 3.5" (480x320)
//  ADC Input: GPIO35, DC bias ~1.6V (2x100k divider)
//
//  TUNED VERSION:
//  - Single FFT over whole spectrum
//  - 1024 samples @ 40kHz  -> much faster than 2048
//  - 24 bars -> better practical resolution on ESP32
//  - Tuned band energy formula
//  - Mild HF tilt compensation
//  - Mild mid cut around 500 Hz – 2 kHz
//  - Noise floor calibration once at startup
// ============================================================

#include <Arduino.h>
#include <TFT_eSPI.h>
#include <arduinoFFT.h>
#include <math.h>

// ─────────────────────────────────────────────
//  Екран
// ─────────────────────────────────────────────
#define SCREEN_WIDTH    480
#define SCREEN_HEIGHT   320
#define MARGIN           35
#define NUM_BARS         24
#define BAR_GAP           2
#define BACKGROUND      TFT_BLACK
#define SCALE_COLOR     0x07FF

// ─────────────────────────────────────────────
//  ADC
// ─────────────────────────────────────────────
#define ADC_PIN          35

// ─────────────────────────────────────────────
//  FFT
//  1024 @ 40kHz -> 39.06 Hz/bin
// ─────────────────────────────────────────────
#define SAMPLES_FFT      1024
#define SAMPLING_FREQ   40000

// ─────────────────────────────────────────────
//  Spectrum range
// ─────────────────────────────────────────────
#define FREQ_MIN        20.0f
#define FREQ_MAX     20000.0f

// ─────────────────────────────────────────────
//  Noise floor
// ─────────────────────────────────────────────
#define NOISE_CAL_FRAMES  12
#define NOISE_MARGIN       1.5f

// ─────────────────────────────────────────────
//  Visual tuning
// ─────────────────────────────────────────────
#define PEAK_HOLD_FRAMES  10
#define FALL_SPEED         25
#define PEAK_FALL_SPEED    12
#define RISE_SMOOTH      0.35f

// ─────────────────────────────────────────────
//  Globals
// ─────────────────────────────────────────────
TFT_eSPI tft = TFT_eSPI();

double vReal[SAMPLES_FFT];
double vImag[SAMPLES_FFT];

ArduinoFFT<double> FFT = ArduinoFFT<double>(vReal, vImag, SAMPLES_FFT, SAMPLING_FREQ);

uint16_t barPalette[NUM_BARS];
float    noiseFloor[NUM_BARS];

float barHeight     [NUM_BARS];
int   prevBarHeight [NUM_BARS];
int   peakHeight    [NUM_BARS];
int   prevPeakHeight[NUM_BARS];
int   peakHoldCount [NUM_BARS];

int displayAreaWidth;
int displayAreaHeight;
int startX;
float barWidth;
int maxBarHeight;

int bandLo[NUM_BARS];
int bandHi[NUM_BARS];

// ─────────────────────────────────────────────
//  slotColor()
// ─────────────────────────────────────────────
uint16_t slotColor(int pixelFromBottom) {
    uint8_t g = (uint8_t)map(pixelFromBottom, 0, displayAreaHeight, 0, 30);
    return tft.color565(g, g, g);
}

// ─────────────────────────────────────────────
//  createPalette()
// ─────────────────────────────────────────────
void createPalette() {
    struct RGB { uint8_t r, g, b; };
    RGB colors[] = {
        {255,   0,   0},
        {255, 255,   0},
        {  0, 255,   0},
        {  0, 255, 255},
        {  0,   0, 255},
        {255,   0, 255}
    };

    for (int i = 0; i < NUM_BARS; i++) {
        float pos = (float)i / (NUM_BARS - 1) * 5.0f;
        int idx = (int)pos;
        float frac = pos - idx;

        uint8_t r, g, b;
        if (idx >= 5) {
            r = colors[5].r;
            g = colors[5].g;
            b = colors[5].b;
        } else {
            r = colors[idx].r + (colors[idx + 1].r - colors[idx].r) * frac;
            g = colors[idx].g + (colors[idx + 1].g - colors[idx].g) * frac;
            b = colors[idx].b + (colors[idx + 1].b - colors[idx].b) * frac;
        }

        barPalette[i] = tft.color565(r, g, b);
    }
}

// ─────────────────────────────────────────────
//  showIntroText()
// ─────────────────────────────────────────────
void showIntroText() {
    tft.fillScreen(TFT_BLACK);
    tft.setTextDatum(MC_DATUM);
    tft.setTextColor(TFT_WHITE, TFT_BLACK);
    tft.drawCentreString("Spectrum Analyzer", SCREEN_WIDTH / 2, 95, 4);
    tft.drawCentreString("by",               SCREEN_WIDTH / 2, 145, 4);
    tft.drawCentreString("mircemk",          SCREEN_WIDTH / 2, 190, 4);
    delay(3000);
}

// ─────────────────────────────────────────────
//  drawTubeBar()
// ─────────────────────────────────────────────
void drawTubeBar(int x, int y, int w, int h, uint16_t baseColor) {
    if (h <= 0 || w <= 0) return;

    uint8_t r_b = (baseColor >> 11) & 0x1F;
    uint8_t g_b = (baseColor >>  5) & 0x3F;
    uint8_t b_b =  baseColor        & 0x1F;

    for (int i = 0; i < w; i++) {
        float intensity = 0.5f + 0.5f * sinf((PI * i) / (w - 1));
        uint16_t litColor = tft.color565(
            (uint8_t)(r_b * intensity) << 3,
            (uint8_t)(g_b * intensity) << 2,
            (uint8_t)(b_b * intensity) << 3
        );
        tft.drawFastVLine(x + i, y, h, litColor);
    }
}

// ─────────────────────────────────────────────
//  drawUI()
// ─────────────────────────────────────────────
void drawUI() {
    tft.fillScreen(BACKGROUND);

    tft.setTextDatum(TC_DATUM);
    tft.setTextColor(TFT_YELLOW, BACKGROUND);
    tft.drawString("Spectrum Analyzer [L]", SCREEN_WIDTH / 2, 5, 4);

    tft.setTextColor(SCALE_COLOR, BACKGROUND);
    const char* dbValues[] = {"+3","-3","-9","-15","-21","-27","-33","-39","-45"};
    float yStep = (float)(SCREEN_HEIGHT - 2 * MARGIN) / 8.0f;

    for (int i = 0; i < 9; i++) {
        int y = MARGIN + (int)(i * yStep);
        tft.drawString(dbValues[i], 18, y - 7, 2);
        tft.drawFastHLine(35, y, 8, SCALE_COLOR);
        tft.drawString(dbValues[i], SCREEN_WIDTH - 18, y - 7, 2);
        tft.drawFastHLine(SCREEN_WIDTH - 43, y, 8, SCALE_COLOR);
    }

    const char* freqValues[] = {"20","50","100","200","500","1K","2K","5K","10K","20K"};
    for (int i = 0; i < 10; i++) {
        int x = MARGIN + 20 + (int)(i * ((SCREEN_WIDTH - 2 * MARGIN - 40) / 9.0f));
        tft.drawString(freqValues[i], x, SCREEN_HEIGHT - MARGIN + 8, 2);
    }

    for (int i = 0; i < NUM_BARS; i++) {
        int xPos = startX + (int)(i * (barWidth + BAR_GAP));
        for (int j = 0; j < displayAreaHeight; j++) {
            tft.drawFastHLine(xPos, (SCREEN_HEIGHT - MARGIN) - j, (int)barWidth, slotColor(j));
        }
    }
}

// ─────────────────────────────────────────────
//  sampleADC()
// ─────────────────────────────────────────────
void sampleADC(double* buf, int nSamples, int freqHz) {
    unsigned long periodUs = 1000000UL / (unsigned long)freqHz;
    unsigned long t0 = micros();
    long dcSum = 0;

    for (int i = 0; i < nSamples; i++) {
        int raw = analogRead(ADC_PIN);
        buf[i] = (double)raw;
        dcSum += raw;
        while ((micros() - t0) < (unsigned long)(i + 1) * periodUs) { }
    }

    int dcMid = (int)(dcSum / nSamples);
    for (int i = 0; i < nSamples; i++) {
        buf[i] -= dcMid;
    }
}

// ─────────────────────────────────────────────
//  prepareBandBounds()
//  Log spaced bands
// ─────────────────────────────────────────────
void prepareBandBounds() {
    float res = (float)SAMPLING_FREQ / (float)SAMPLES_FFT;

    for (int b = 0; b < NUM_BARS; b++) {
        float fLo = FREQ_MIN * powf(FREQ_MAX / FREQ_MIN, (float)b / NUM_BARS);
        float fHi = FREQ_MIN * powf(FREQ_MAX / FREQ_MIN, (float)(b + 1) / NUM_BARS);

        int lo = max(1, (int)(fLo / res));
        int hi = min((SAMPLES_FFT / 2) - 1, (int)(fHi / res));

        if (hi < lo) hi = lo;

        // Avoid too many identical first bands
        if (b > 0 && lo <= bandHi[b - 1]) {
            lo = bandHi[b - 1] + 1;
            if (lo > hi) hi = lo;
            if (hi > (SAMPLES_FFT / 2) - 1) hi = (SAMPLES_FFT / 2) - 1;
            if (lo > hi) lo = hi;
        }

        bandLo[b] = lo;
        bandHi[b] = hi;
    }

    // Safety fix: ensure valid order
    for (int b = 0; b < NUM_BARS; b++) {
        if (bandLo[b] < 1) bandLo[b] = 1;
        if (bandHi[b] < bandLo[b]) bandHi[b] = bandLo[b];
        if (bandHi[b] > (SAMPLES_FFT / 2) - 1) bandHi[b] = (SAMPLES_FFT / 2) - 1;
    }
}

// ─────────────────────────────────────────────
//  runFFT()
//  Tuned aggregation:
//  - sumPower from bins
//  - divide by count^0.65  (compromise between sum and avg)
//  - mild HF tilt
//  - mild mid cut 500Hz..2kHz
// ─────────────────────────────────────────────
void runFFT(float* outRaw) {
    for (int i = 0; i < SAMPLES_FFT; i++) vImag[i] = 0.0;

    FFT.windowing(FFTWindow::Hamming, FFTDirection::Forward);
    FFT.compute(FFTDirection::Forward);
    FFT.complexToMagnitude();

    float binHz = (float)SAMPLING_FREQ / (float)SAMPLES_FFT;

    for (int b = 0; b < NUM_BARS; b++) {
        double sumPower = 0.0;
        int count = 0;

        for (int bin = bandLo[b]; bin <= bandHi[b]; bin++) {
            double mag = vReal[bin];
            sumPower += (mag * mag);
            count++;
        }

        float val = (count > 0)
            ? (float)(sqrt(sumPower) / pow((double)count, 0.65))
            : 0.0f;

        float pos = (float)b / (NUM_BARS - 1);     // 0..1
        float tilt = 1.0f + pos * 2.25f;           // 1.0 .. 2.25
        val *= tilt;

        float fCenter = sqrtf((bandLo[b] * binHz) * (bandHi[b] * binHz));
        if (fCenter > 500.0f && fCenter < 2000.0f) {
            val *= 0.42f;
        }

        outRaw[b] = val;
    }
}

// ─────────────────────────────────────────────
//  calibrateNoise()
// ─────────────────────────────────────────────
void calibrateNoise() {
    tft.setTextDatum(MC_DATUM);
    tft.setTextColor(TFT_CYAN, BACKGROUND);
    tft.drawCentreString("Calibrating...", SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2 - 20, 4);
    tft.setTextColor(TFT_DARKGREY, BACKGROUND);
    tft.drawCentreString("Keep audio input silent", SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2 + 15, 2);

    for (int i = 0; i < NUM_BARS; i++) noiseFloor[i] = 0.0f;

    float raw[NUM_BARS];

    for (int frame = 0; frame < NOISE_CAL_FRAMES; frame++) {
        sampleADC(vReal, SAMPLES_FFT, SAMPLING_FREQ);
        runFFT(raw);

        for (int i = 0; i < NUM_BARS; i++) {
            if (raw[i] > noiseFloor[i]) noiseFloor[i] = raw[i];
        }

        int pw = (SCREEN_WIDTH - 80) * (frame + 1) / NOISE_CAL_FRAMES;
        tft.fillRect(40, SCREEN_HEIGHT / 2 + 40, pw, 10, TFT_CYAN);
    }

    for (int i = 0; i < NUM_BARS; i++) {
        if (noiseFloor[i] < 8.0f) noiseFloor[i] = 8.0f;
    }

    drawUI();
}

// ─────────────────────────────────────────────
//  mapBandToHeight()
// ─────────────────────────────────────────────
int mapBandToHeight(float magnitude) {
    if (magnitude < 1.0f) return 0;

    float db = 20.0f * log10f(magnitude);

    // Slightly more aggressive than 75 dB
    float normalized = constrain(db / 95.0f, 0.0f, 1.0f);
    return (int)(normalized * maxBarHeight);
}

// ─────────────────────────────────────────────
//  updateBars()
// ─────────────────────────────────────────────
void updateBars(float* bands) {
    for (int i = 0; i < NUM_BARS; i++) {
        float targetH = (float)mapBandToHeight(bands[i]);

        if (targetH >= barHeight[i]) {
            barHeight[i] += (targetH - barHeight[i]) * RISE_SMOOTH;
            if ((targetH - barHeight[i]) < 0.5f) barHeight[i] = targetH;
        } else {
            barHeight[i] -= FALL_SPEED;
            if (barHeight[i] < 0.0f) barHeight[i] = 0.0f;
        }

        int barH_int = (int)barHeight[i];

        if (barH_int >= peakHeight[i]) {
            peakHeight[i] = barH_int;
            peakHoldCount[i] = PEAK_HOLD_FRAMES;
        } else {
            if (peakHoldCount[i] > 0) {
                peakHoldCount[i]--;
            } else {
                peakHeight[i] -= PEAK_FALL_SPEED;
                if (peakHeight[i] < 0) peakHeight[i] = 0;
            }
        }
    }
}

// ─────────────────────────────────────────────
//  redrawBars()
// ─────────────────────────────────────────────
void redrawBars() {
    int bottomY = SCREEN_HEIGHT - MARGIN;

    for (int i = 0; i < NUM_BARS; i++) {
        int xPos = startX + (int)(i * (barWidth + BAR_GAP));
        int w    = (int)barWidth;
        int newH = (int)barHeight[i];
        int oldH = prevBarHeight[i];
        int newP = peakHeight[i];
        int oldP = prevPeakHeight[i];

        if (newH > oldH) {
            drawTubeBar(xPos, bottomY - newH, w, newH - oldH, barPalette[i]);
        } else if (newH < oldH) {
            for (int px = newH; px < oldH; px++) {
                tft.drawFastHLine(xPos, bottomY - px - 1, w, slotColor(px));
            }
        }

        if (oldP > 0 && (oldP != newP || oldP <= newH)) {
            int oldPY = bottomY - oldP - 1;
            if (oldPY >= MARGIN && oldP > newH) {
                tft.drawFastHLine(xPos, oldPY, w, slotColor(oldP));
            }
        }

        if (newP > newH + 1) {
            int newPY = bottomY - newP - 1;
            if (newPY >= MARGIN) {
                tft.drawFastHLine(xPos, newPY, w, TFT_WHITE);
            }
        }

        prevBarHeight[i]  = newH;
        prevPeakHeight[i] = (newP > newH + 1) ? newP : 0;
    }
}

// ─────────────────────────────────────────────
//  setup()
// ─────────────────────────────────────────────
void setup() {
    Serial.begin(115200);

    analogSetAttenuation(ADC_11db);
    analogReadResolution(12);
    pinMode(ADC_PIN, INPUT);

    tft.begin();
    tft.setRotation(1);

    displayAreaWidth  = SCREEN_WIDTH  - (2 * MARGIN) - 30;
    displayAreaHeight = SCREEN_HEIGHT - (2 * MARGIN);
    startX            = MARGIN + 15;
    barWidth          = (float)(displayAreaWidth - (NUM_BARS - 1) * BAR_GAP) / NUM_BARS;
    maxBarHeight      = displayAreaHeight;

    createPalette();
    prepareBandBounds();
    showIntroText();
    drawUI();
    calibrateNoise();

    memset(barHeight,      0, sizeof(barHeight));
    memset(prevBarHeight,  0, sizeof(prevBarHeight));
    memset(peakHeight,     0, sizeof(peakHeight));
    memset(prevPeakHeight, 0, sizeof(prevPeakHeight));
    memset(peakHoldCount,  0, sizeof(peakHoldCount));
}

// ─────────────────────────────────────────────
//  loop()
// ─────────────────────────────────────────────
void loop() {
    float raw[NUM_BARS];
    float bands[NUM_BARS];

    sampleADC(vReal, SAMPLES_FFT, SAMPLING_FREQ);
    runFFT(raw);

    for (int i = 0; i < NUM_BARS; i++) {
        float sig = raw[i] - noiseFloor[i] * NOISE_MARGIN;
        bands[i] = (sig < 0.0f) ? 0.0f : sig;
    }

    updateBars(bands);
    redrawBars();
}

Code 32 Bars

C/C++
...
// by mircemk, July 2026

// ============================================================
//  Audio Spectrum Analyzer V1.4
//  Hardware : Elecrow CrowPanel ESP32 3.5" (480x320)
//  ADC Input: GPIO35, DC bias ~1.6V (2x100k делитељ)
//             Препорака: 10kΩ pull-down по влезниот кондензатор
//
//  Noise floor: мери се ЕДНАШ при стартување, потоа е фиксен.
//  Нема адаптација – нема drift.
//  by mircemk April, 2026
// ============================================================

#include <Arduino.h>
#include <TFT_eSPI.h>
#include <arduinoFFT.h>

// ─────────────────────────────────────────────
//  Екран
// ─────────────────────────────────────────────
#define SCREEN_WIDTH    480
#define SCREEN_HEIGHT   320
#define MARGIN           35
#define NUM_BARS         32
#define BAR_GAP           2
#define BACKGROUND      TFT_BLACK
#define SCALE_COLOR     0x07FF

// ─────────────────────────────────────────────
//  ADC
// ─────────────────────────────────────────────
#define ADC_PIN          35

// ─────────────────────────────────────────────
//  Dual FFT параметри
//  LOW  : 256 samples @ 4000 Hz  → 15.6 Hz/бин → барови  0–15 : 20 Hz – 2 kHz
//  HIGH : 256 samples @ 40000 Hz → 156  Hz/бин → барови 16–31 : 2 kHz – 20 kHz
// ─────────────────────────────────────────────
#define SAMPLES_LOW      256
#define SAMPLING_LOW    4000

#define SAMPLES_HIGH     256
#define SAMPLING_HIGH  40000

#define NUM_BARS_LOW     16
#define NUM_BARS_HIGH    16

// ─────────────────────────────────────────────
//  Noise floor – калибрирање при старт
//
//  NOISE_CAL_FRAMES : колку FFT фрејми се мерат (повеќе = попрецизно)
//  NOISE_MARGIN     : noise floor × овој множител = pragma за 0-барови
//                     Зголеми ако гледаш шум при тишина (пробај 1.8, 2.0)
//                     Намали ако тивките сигнали исчезнуваат (пробај 1.2)
// ─────────────────────────────────────────────
#define NOISE_CAL_FRAMES  12
#define NOISE_MARGIN       1.5f

// ─────────────────────────────────────────────
//  Визуелни параметри
// ─────────────────────────────────────────────
#define PEAK_HOLD_FRAMES  5
#define FALL_SPEED        25
#define PEAK_FALL_SPEED    12

// Smoothing при качување нагоре (EMA коефициент)
// 0.25 = бавно/мазно,  0.5 = средно,  0.8 = брзо/скоро моментално
// Препорака: 0.35 за музика, 0.5 за говор
#define RISE_SMOOTH       0.9f

// ─────────────────────────────────────────────
//  Глобали
// ─────────────────────────────────────────────
TFT_eSPI tft = TFT_eSPI();

double vRealLow [SAMPLES_LOW],  vImagLow [SAMPLES_LOW];
double vRealHigh[SAMPLES_HIGH], vImagHigh[SAMPLES_HIGH];

ArduinoFFT<double> FFT_Low  = ArduinoFFT<double>(vRealLow,  vImagLow,  SAMPLES_LOW,  SAMPLING_LOW);
ArduinoFFT<double> FFT_High = ArduinoFFT<double>(vRealHigh, vImagHigh, SAMPLES_HIGH, SAMPLING_HIGH);

uint16_t barPalette[NUM_BARS];
float    noiseFloor[NUM_BARS];   // фиксен праг по бар, пресметан при старт

float barHeight     [NUM_BARS];   // float за прецизен EMA
int   prevBarHeight [NUM_BARS];   // int за цртање (пиксели)
int   peakHeight    [NUM_BARS];
int   prevPeakHeight[NUM_BARS];
int   peakHoldCount [NUM_BARS];

int   displayAreaWidth;
int   displayAreaHeight;
int   startX;
float barWidth;
int   maxBarHeight;

// ─────────────────────────────────────────────
//  slotColor()
// ─────────────────────────────────────────────
uint16_t slotColor(int pixelFromBottom) {
    uint8_t g = (uint8_t)map(pixelFromBottom, 0, displayAreaHeight, 5, 60);
    return tft.color565(g, g, g);
}

// ─────────────────────────────────────────────
//  createPalette()
// ─────────────────────────────────────────────
void createPalette() {
    struct RGB { uint8_t r, g, b; };
    RGB colors[] = {
        {255,   0,   0},
        {255, 255,   0},
        {  0, 255,   0},
        {  0, 255, 255},
        {  0,   0, 255},
        {255,   0, 255}
    };
    for (int i = 0; i < NUM_BARS; i++) {
        float pos   = (float)i / 31.0f * 5.0f;
        int   idx   = (int)pos;
        float frac  = pos - idx;
        uint8_t r, g, b;
        if (idx >= 5) {
            r = colors[5].r; g = colors[5].g; b = colors[5].b;
        } else {
            r = colors[idx].r + (colors[idx+1].r - colors[idx].r) * frac;
            g = colors[idx].g + (colors[idx+1].g - colors[idx].g) * frac;
            b = colors[idx].b + (colors[idx+1].b - colors[idx].b) * frac;
        }
        barPalette[i] = tft.color565(r, g, b);
    }
}

// ─────────────────────────────────────────────
//  showIntroText()
// ─────────────────────────────────────────────
void showIntroText() {
    tft.fillScreen(TFT_BLACK);
    tft.setTextDatum(MC_DATUM);
    tft.setTextColor(TFT_WHITE, TFT_BLACK);
    tft.drawCentreString("Spectrum Analyzer", SCREEN_WIDTH / 2, 95, 4);
    tft.drawCentreString("by",               SCREEN_WIDTH / 2, 145, 4);
    tft.drawCentreString("mircemk",          SCREEN_WIDTH / 2, 190, 4);
    delay(3000);
}

// ─────────────────────────────────────────────
//  drawTubeBar()
// ─────────────────────────────────────────────
void drawTubeBar(int x, int y, int w, int h, uint16_t baseColor) {
    if (h <= 0 || w <= 0) return;
    uint8_t r_b = (baseColor >> 11) & 0x1F;
    uint8_t g_b = (baseColor >>  5) & 0x3F;
    uint8_t b_b =  baseColor        & 0x1F;
    for (int i = 0; i < w; i++) {
        float    intensity = 0.5f + 0.5f * sinf((PI * i) / (w - 1));
        uint16_t litColor  = tft.color565(
            (uint8_t)(r_b * intensity) << 3,
            (uint8_t)(g_b * intensity) << 2,
            (uint8_t)(b_b * intensity) << 3
        );
        tft.drawFastVLine(x + i, y, h, litColor);
    }
}

// ─────────────────────────────────────────────
//  drawUI()
// ─────────────────────────────────────────────
void drawUI() {
    tft.fillScreen(BACKGROUND);

    tft.setTextDatum(TC_DATUM);
    tft.setTextColor(TFT_YELLOW, BACKGROUND);
    tft.drawString("Spectrum Analyzer [L]", SCREEN_WIDTH / 2, 5, 4);

    tft.setTextColor(SCALE_COLOR, BACKGROUND);
    const char* dbValues[] = {"+3","-3","-9","-15","-21","-27","-33","-39","-45"};
    float yStep = (float)(SCREEN_HEIGHT - 2 * MARGIN) / 8.0f;
    for (int i = 0; i < 9; i++) {
        int y = MARGIN + (int)(i * yStep);
        tft.drawString(dbValues[i], 18, y - 7, 2);
        tft.drawFastHLine(35, y, 8, SCALE_COLOR);
        tft.drawString(dbValues[i], SCREEN_WIDTH - 18, y - 7, 2);
        tft.drawFastHLine(SCREEN_WIDTH - 43, y, 8, SCALE_COLOR);
    }

    const char* freqValues[] = {"20","50","100","200","500","1K","2K","5K","10K","20K"};
    for (int i = 0; i < 10; i++) {
        int x = MARGIN + 20 + (int)(i * ((SCREEN_WIDTH - 2*MARGIN - 40) / 9.0f));
        tft.drawString(freqValues[i], x, SCREEN_HEIGHT - MARGIN + 8, 2);
    }

    for (int i = 0; i < NUM_BARS; i++) {
        int xPos = startX + (int)(i * (barWidth + BAR_GAP));
        for (int j = 0; j < displayAreaHeight; j++) {
            tft.drawFastHLine(xPos, (SCREEN_HEIGHT - MARGIN) - j,
                              (int)barWidth, slotColor(j));
        }
    }
}

// ─────────────────────────────────────────────
//  sampleADC() – мостри со точен timing + DC отстранување
// ─────────────────────────────────────────────
void sampleADC(double* buf, int nSamples, int freqHz) {
    unsigned long periodUs = 1000000UL / (unsigned long)freqHz;
    unsigned long t0       = micros();
    long dcSum = 0;

    for (int i = 0; i < nSamples; i++) {
        int raw = analogRead(ADC_PIN);
        buf[i]  = (double)raw;
        dcSum  += raw;
        while (micros() - t0 < (unsigned long)(i + 1) * periodUs);
    }
    int dcMid = (int)(dcSum / nSamples);
    for (int i = 0; i < nSamples; i++) buf[i] -= dcMid;
}

// ─────────────────────────────────────────────
//  getBinBounds() – логаритамски бин граници
// ─────────────────────────────────────────────
void getBinBounds(int nSamples, int freqHz,
                  float fMin, float fMax,
                  int numBands,
                  int* binLo, int* binHi)
{
    float res = (float)freqHz / nSamples;
    for (int b = 0; b < numBands; b++) {
        float f_lo = fMin * powf(fMax / fMin, (float) b      / numBands);
        float f_hi = fMin * powf(fMax / fMin, (float)(b + 1) / numBands);
        int lo = max(1,              (int)(f_lo / res));
        int hi = min(nSamples/2 - 1, (int)(f_hi / res));
        if (hi < lo) hi = lo;
        binLo[b] = lo;
        binHi[b] = hi;
    }
}

// ─────────────────────────────────────────────
//  doFFT() – windowing + compute + magnitude
//  Враќа сурова max-магнитуда по бар (без noise субтракција)
// ─────────────────────────────────────────────
void doFFT(ArduinoFFT<double>& fft,
           double* realBuf, double* imagBuf,
           int nSamples,
           int* binLo, int* binHi,
           int numBands, int offset,
           float* outRaw)
{
    for (int i = 0; i < nSamples; i++) imagBuf[i] = 0.0;
    fft.windowing(FFTWindow::Hamming, FFTDirection::Forward);
    fft.compute(FFTDirection::Forward);
    fft.complexToMagnitude();

    for (int b = 0; b < numBands; b++) {
        double mx = 0.0;
        for (int bin = binLo[b]; bin <= binHi[b]; bin++) {
            if (realBuf[bin] > mx) mx = realBuf[bin];
        }
        outRaw[offset + b] = (float)mx;
    }
}

// ─────────────────────────────────────────────
//  calibrateNoise()
//  Мери NOISE_CAL_FRAMES фрејми при тишина,
//  зема максимумот (не просекот!) по бар → сигурен праг.
//  Потоа noiseFloor[] е ФИКСЕН до следниот ресет.
// ─────────────────────────────────────────────
void calibrateNoise() {
    // Подготви бин граници (истите ќе ги користи и главниот loop)
    static int binLoL[NUM_BARS_LOW],  binHiL[NUM_BARS_LOW];
    static int binLoH[NUM_BARS_HIGH], binHiH[NUM_BARS_HIGH];
    getBinBounds(SAMPLES_LOW,  SAMPLING_LOW,  20.0f,   2000.0f,  NUM_BARS_LOW,  binLoL, binHiL);
    getBinBounds(SAMPLES_HIGH, SAMPLING_HIGH, 2000.0f, 20000.0f, NUM_BARS_HIGH, binLoH, binHiH);

    // Прикажи порака
    tft.setTextDatum(MC_DATUM);
    tft.setTextColor(TFT_CYAN, BACKGROUND);
    tft.drawCentreString("Calibrating...", SCREEN_WIDTH/2, SCREEN_HEIGHT/2 - 20, 4);
    tft.setTextColor(TFT_DARKGREY, BACKGROUND);
    tft.drawCentreString("Keep audio input silent", SCREEN_WIDTH/2, SCREEN_HEIGHT/2 + 15, 2);

    // Иницијализирај на 0
    for (int i = 0; i < NUM_BARS; i++) noiseFloor[i] = 0.0f;

    float raw[NUM_BARS];

    for (int frame = 0; frame < NOISE_CAL_FRAMES; frame++) {

        // LOW FFT
        sampleADC(vRealLow, SAMPLES_LOW, SAMPLING_LOW);
        doFFT(FFT_Low, vRealLow, vImagLow,
              SAMPLES_LOW, binLoL, binHiL,
              NUM_BARS_LOW, 0, raw);

        // HIGH FFT
        sampleADC(vRealHigh, SAMPLES_HIGH, SAMPLING_HIGH);
        doFFT(FFT_High, vRealHigh, vImagHigh,
              SAMPLES_HIGH, binLoH, binHiH,
              NUM_BARS_HIGH, NUM_BARS_LOW, raw);

        // Земи МАКСИМУМ по бар низ сите фрејми
        // (максимум е посигурен од просек – покрива шумни пикови)
        for (int i = 0; i < NUM_BARS; i++) {
            if (raw[i] > noiseFloor[i]) noiseFloor[i] = raw[i];
        }

        // Прогрес бар
        int pw = (SCREEN_WIDTH - 80) * (frame + 1) / NOISE_CAL_FRAMES;
        tft.fillRect(40, SCREEN_HEIGHT/2 + 40, pw, 10, TFT_CYAN);
    }

    // Минимален праг за секој бар (штити од 0-делење и premala вредност)
    for (int i = 0; i < NUM_BARS; i++) {
        if (noiseFloor[i] < 8.0f) noiseFloor[i] = 8.0f;
    }

    // Прецртај UI, готово
    drawUI();
}

// ─────────────────────────────────────────────
//  mapBandToHeight()
// ─────────────────────────────────────────────
int mapBandToHeight(float magnitude) {
    if (magnitude < 1.0f) return 0;
    float db = 20.0f * log10f(magnitude);
    float normalized = constrain(db / 95.0f, 0.0f, 1.0f);
    return (int)(normalized * maxBarHeight);
}

// ─────────────────────────────────────────────
//  updateBars()
//  Качување: EMA smoothing (мазно, органско)
//  Паѓање:   линеарно со FALL_SPEED px/фрејм
// ─────────────────────────────────────────────
void updateBars(float* bands) {
    for (int i = 0; i < NUM_BARS; i++) {
        float targetH = (float)mapBandToHeight(bands[i]);

        if (targetH >= barHeight[i]) {
            // Качување – EMA: движи се дел од патот кон таргетот
            barHeight[i] += (targetH - barHeight[i]) * RISE_SMOOTH;
            // Ако сме многу блиску → скокни до таргет (избегни бесконечно приближување)
            if (targetH - barHeight[i] < 0.5f) barHeight[i] = targetH;
        } else {
            // Паѓање – линеарно
            barHeight[i] -= FALL_SPEED;
            if (barHeight[i] < 0.0f) barHeight[i] = 0.0f;
        }

        int barH_int = (int)barHeight[i];

        if (barH_int >= peakHeight[i]) {
            peakHeight[i]    = barH_int;
            peakHoldCount[i] = PEAK_HOLD_FRAMES;
        } else {
            if (peakHoldCount[i] > 0) {
                peakHoldCount[i]--;
            } else {
                peakHeight[i] -= PEAK_FALL_SPEED;
                if (peakHeight[i] < 0) peakHeight[i] = 0;
            }
        }
    }
}

// ─────────────────────────────────────────────
//  redrawBars()
// ─────────────────────────────────────────────
void redrawBars() {
    int bottomY = SCREEN_HEIGHT - MARGIN;

    for (int i = 0; i < NUM_BARS; i++) {
        int xPos = startX + (int)(i * (barWidth + BAR_GAP));
        int w    = (int)barWidth;
        int newH = (int)barHeight[i];   // float → int за цртање
        int oldH = prevBarHeight[i];
        int newP = peakHeight[i];
        int oldP = prevPeakHeight[i];

        // Ажурирај бар
        if (newH > oldH) {
            drawTubeBar(xPos, bottomY - newH, w, newH - oldH, barPalette[i]);
        } else if (newH < oldH) {
            for (int px = newH; px < oldH; px++) {
                tft.drawFastHLine(xPos, bottomY - px - 1, w, slotColor(px));
            }
        }

        // Избриши стара peak линија
        if (oldP > 0 && (oldP != newP || oldP <= newH)) {
            int oldPY = bottomY - oldP - 1;
            if (oldPY >= MARGIN && oldP > newH) {
                tft.drawFastHLine(xPos, oldPY, w, slotColor(oldP));
            }
        }

        // Нацртај нова peak линија
        if (newP > newH + 1) {
            int newPY = bottomY - newP - 1;
            if (newPY >= MARGIN) {
                tft.drawFastHLine(xPos, newPY, w, TFT_WHITE);
            }
        }

        prevBarHeight[i]  = newH;
        prevPeakHeight[i] = (newP > newH + 1) ? newP : 0;
    }
}

// ─────────────────────────────────────────────
//  setup()
// ─────────────────────────────────────────────
void setup() {
    Serial.begin(115200);

    analogSetAttenuation(ADC_11db);
    analogReadResolution(12);
    pinMode(ADC_PIN, INPUT);

    tft.begin();
    tft.setRotation(1);

    displayAreaWidth  = SCREEN_WIDTH  - (2 * MARGIN) - 30;
    displayAreaHeight = SCREEN_HEIGHT - (2 * MARGIN);
    startX            = MARGIN + 15;
    barWidth          = (float)(displayAreaWidth - (NUM_BARS - 1) * BAR_GAP) / NUM_BARS;
    maxBarHeight      = displayAreaHeight;

    createPalette();
    showIntroText();
    drawUI();
    calibrateNoise();   // ← ЕДНАШ, фиксно, нема повеќе промени

    memset(barHeight,      0, sizeof(barHeight));   // float низа, 0.0f = 0x00000000 ✓
    memset(prevBarHeight,  0, sizeof(prevBarHeight));
    memset(peakHeight,     0, sizeof(peakHeight));
    memset(prevPeakHeight, 0, sizeof(prevPeakHeight));
    memset(peakHoldCount,  0, sizeof(peakHoldCount));
}

// ─────────────────────────────────────────────
//  loop()
// ─────────────────────────────────────────────
void loop() {
    // Бин граници се пресметуваат еднаш и кешираат
    static int   binLoL[NUM_BARS_LOW],  binHiL[NUM_BARS_LOW];
    static int   binLoH[NUM_BARS_HIGH], binHiH[NUM_BARS_HIGH];
    static bool  binsReady = false;
    if (!binsReady) {
        getBinBounds(SAMPLES_LOW,  SAMPLING_LOW,  20.0f,   2000.0f,  NUM_BARS_LOW,  binLoL, binHiL);
        getBinBounds(SAMPLES_HIGH, SAMPLING_HIGH, 2000.0f, 20000.0f, NUM_BARS_HIGH, binLoH, binHiH);
        binsReady = true;
    }

    float raw[NUM_BARS];
    float bands[NUM_BARS];

    // LOW FFT → raw[0..15]
    sampleADC(vRealLow, SAMPLES_LOW, SAMPLING_LOW);
    doFFT(FFT_Low, vRealLow, vImagLow,
          SAMPLES_LOW, binLoL, binHiL,
          NUM_BARS_LOW, 0, raw);

    // HIGH FFT → raw[16..31]
    sampleADC(vRealHigh, SAMPLES_HIGH, SAMPLING_HIGH);
    doFFT(FFT_High, vRealHigh, vImagHigh,
          SAMPLES_HIGH, binLoH, binHiH,
          NUM_BARS_HIGH, NUM_BARS_LOW, raw);

    // Одземи noise floor × margin → bands[]
    for (int i = 0; i < NUM_BARS; i++) {
        float sig = raw[i] - noiseFloor[i] * NOISE_MARGIN;
        bands[i] = (sig < 0.0f) ? 0.0f : sig;
    }

    updateBars(bands);
    redrawBars();
}

Credits

Mirko Pavleski
248 projects • 1682 followers
Service specialist

Comments