// 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 #include #include // ───────────────────────────────────────────── // Екран // ───────────────────────────────────────────── #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 FFT_Low = ArduinoFFT(vRealLow, vImagLow, SAMPLES_LOW, SAMPLING_LOW); ArduinoFFT FFT_High = ArduinoFFT(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& 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(); }