Hardware components | ||||||
| × | 2 | ||||
Software apps and online services | ||||||
![]() |
| |||||
Hand tools and fabrication machines | ||||||
![]() |
| |||||
![]() |
| |||||
An audio spectrum analyzer is a tool that shows the frequency content of sound in real time, helping visualize how loud different frequencies are within an audio signal. In one of my previous videos, I described how to build a Stereo VU meter on CrowPanel 3.5"-HMI ESP32 Displays. This time I will describe how to build a visually effective stereo Audio Spectrum Analyzer on these same display modules. I will even use the same housing design and audio input section.
So the schematic is basically the same with the note that for the input signal I use the GPIO35 pin from which I have directly extracted a thin wire, for reasons that I explained in the above-mentioned VU meter project.
This project is sponsored by PCBWay. Don't forget to visit the PCBWay Open Source Community where you will find countless useful projects divided into different categories. Choose your area of interest where you will find many useful and free projects. You can here also download Gerber files or order ready-made PCBs for some of your future projects. PCBWay has all the services you need to create your project at the best price.
The input AC signal is fed to the GPIO35 pin, to which a reference voltage of 1.6V has previously been applied through two 100K resistors. The positive and negative values of the input audio signal move around this voltage. The capacitor serves to block any possible DC component, and the 1K resistor to protect the input from inappropriate signals.
As for the code, it is made to be highly customizable and by changing the variables defined at the beginning, you can achieve any type of bar movement you want. All of these parameters affect the way and speed of movement of the Bars.
Two different codes are given, one with 32 bars and a specific way of processing frequencies (the range from 20Hz to 20kHz is divided into two parts and those two parts are processed separately), and the other, a standard code with 24 bars. To upload the code without errors, you need to select "ESP32 DEV Module" in the Board Manager and install the libraries given at the end of the text. I would like to mention that it is especially important to use the given TFT_eSPI library, since it is specifically set for this project.
It is worth mentioning that this is not a precise audio instrument, but primarily an interesting visual addition to the audio system. In this project, the sound is processed in a way that the extremely low and high tones are amplified at the expense of the midrange band, which is naturally the most intense, and all this for a better visual impression. Also, in reality, the bars are 3D processed and look like thin tubes, which is not noticeable in the video.
Now let's see how the device behaves in real conditions with different input signals. When starting the device, the input signal must be at zero, because in the first few seconds is performed a noise floor calibration.
And finally a short conclusion. The CrowPanel 3.5" ESP32 proves to be an incredibly versatile platform for high-speed audio visualization. By combining custom FFT logic with the optimized TFT_eSPI library, I’ve created a audio analyzer with impressive visual effect.
// 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();
}
// 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();
}











Comments