/*
  16x2 LCD Spectrum Analyzer
  Adapted for:
  Elecrow All-in-One Starter Kit for Arduino Nano R4

  Hardware connections on the kit:
  --------------------------------
  Microphone / Sound sensor : A1
  Three buttons             : A3
  LCD SDA                    : A4
  LCD SCL                    : A5
  LCD I2C address            : 0x27

  K1 changes the spectrum display style.

  by mircemk July 2026

*/

#include <Wire.h>
#include <LiquidCrystal_I2C.h>
#include <arduinoFFT.h>

// =====================================================
// ELECROW KIT PINS
// =====================================================

#define AUDIO_PIN   A1
#define BUTTON_PIN  A3

// =====================================================
// LCD CONFIGURATION
// Same configuration as the official Elecrow LCD code
// =====================================================

#define LCD_COLUMNS 16
#define LCD_ROWS     2

LiquidCrystal_I2C lcd(
  PCF8574_ADDR_A21_A11_A01,
  4, 5, 6, 16, 11, 12, 13, 14,
  POSITIVE
);

// =====================================================
// FFT CONFIGURATION
// =====================================================

const uint16_t SAMPLES = 256;

// 8000 Hz gives a maximum detectable frequency of 4000 Hz.
const double SAMPLING_FREQUENCY = 8000.0;

double vReal[SAMPLES];
double vImag[SAMPLES];

ArduinoFFT<double> FFT =
  ArduinoFFT<double>(
    vReal,
    vImag,
    SAMPLES,
    SAMPLING_FREQUENCY
  );

// FFT bins used for the 16 LCD columns.
// With 8000 Hz / 256 samples:
// one FFT bin is approximately 31.25 Hz.
const uint8_t spectrumBins[16] = {
  2,  3,  4,  6,
  8, 10, 12, 14,
  16, 18, 20, 22,
  24, 26, 28, 30
};

// =====================================================
// SPECTRUM SETTINGS
// =====================================================

// Increase this if the display reacts to background noise.
// Decrease it if the spectrum reacts too weakly.
const double NOISE_FLOOR_DB = 20.0;

// Minimum dynamic range above the noise floor.
const double MIN_DYNAMIC_RANGE_DB = 8.0;

// Additional space above the strongest peak.
const double PEAK_HEADROOM_DB = 4.0;

// Spectrum movement smoothing.
// Higher value = faster movement.
// Lower value = smoother movement.
const float BAR_ATTACK = 0.95;
const float BAR_RELEASE = 0.55;

// Automatic gain smoothing.
const float AUTO_GAIN_SPEED = 0.12;

// Displayed bar levels: 0 to 15.
float displayedLevel[16];

// Automatically adjusted upper spectrum limit.
double autoGainTopDB = 55.0;

// =====================================================
// DISPLAY MODES
// =====================================================

// Six pixel patterns from the original project.
const uint8_t modePattern[6] = {
  4,    // 00100
  14,   // 01110
  10,   // 01010
  27,   // 11011
  31,   // 11111
  21    // 10101
};

uint8_t spectrumMode = 0;

// Button debounce / release detection.
bool buttonWasPressed = false;
unsigned long lastButtonTime = 0;

// =====================================================
// FUNCTION PROTOTYPES
// =====================================================

void createSpectrumCharacters();
void readModeButton();
bool isK1Pressed();
void collectAudioSamples();
void calculateSpectrum();
void drawSpectrum();
double magnitudeToDb(double magnitude);

// =====================================================
// SETUP
// =====================================================

void setup() {
  Serial.begin(115200);

  pinMode(AUDIO_PIN, INPUT);
  pinMode(BUTTON_PIN, INPUT);

  // Default analogRead resolution used by the official
  // Elecrow button example is 10 bits: 0 to 1023.
  analogReadResolution(10);

  Wire.begin();

  lcd.begin(LCD_COLUMNS, LCD_ROWS, LCD_5x8DOTS);
  lcd.clear();
  lcd.backlight();

  createSpectrumCharacters();

  lcd.setCursor(0, 0);
  lcd.print("Spectrum");
  lcd.setCursor(0, 1);
  lcd.print("Analyzer Nano R4");

  delay(1200);
  lcd.clear();

  // Initial ADC read after selecting A1.
  // This helps the ADC input settle.
  analogRead(AUDIO_PIN);
}

// =====================================================
// MAIN LOOP
// =====================================================

void loop() {
  readModeButton();

  collectAudioSamples();
  calculateSpectrum();
  drawSpectrum();
}

// =====================================================
// READ K1 BUTTON ON A3
// =====================================================

void readModeButton() {
  bool pressed = isK1Pressed();

  if (pressed && !buttonWasPressed) {
    if (millis() - lastButtonTime > 250) {
      spectrumMode++;

      if (spectrumMode > 5) {
        spectrumMode = 0;
      }

      createSpectrumCharacters();

      lastButtonTime = millis();
    }

    buttonWasPressed = true;
  }

  if (!pressed) {
    buttonWasPressed = false;
  }
}

bool isK1Pressed() {
  /*
    Read twice because the ADC was previously reading A1.
    The first read allows the ADC multiplexer to settle.
  */

  analogRead(BUTTON_PIN);
  int buttonValue = analogRead(BUTTON_PIN);

  /*
    Official Elecrow values:

    K1 approximately 500-520
    K2 approximately 680-690
    K3 approximately 845-860

    A slightly wider range is used for tolerance.
  */

  if (buttonValue >= 470 && buttonValue <= 550) {
    return true;
  }

  return false;
}

// =====================================================
// CREATE THE 8 CUSTOM LCD BAR CHARACTERS
// =====================================================

void createSpectrumCharacters() {
  uint8_t pixelPattern = modePattern[spectrumMode];

  for (uint8_t characterNumber = 0;
       characterNumber < 8;
       characterNumber++) {

    uint8_t customCharacter[8];

    /*
      Character 0 has one active row.
      Character 7 has all eight rows active.
    */

    for (uint8_t row = 0; row < 8; row++) {
      if (row >= (7 - characterNumber)) {
        customCharacter[row] = pixelPattern;
      } else {
        customCharacter[row] = 0;
      }
    }

    lcd.createChar(characterNumber, customCharacter);
  }
}

// =====================================================
// AUDIO SAMPLING
// =====================================================

void collectAudioSamples() {
  const uint32_t samplingPeriodMicroseconds =
    1000000UL / SAMPLING_FREQUENCY;

  // Allow ADC multiplexer to settle after reading A3.
  analogRead(AUDIO_PIN);

  for (uint16_t i = 0; i < SAMPLES; i++) {
    uint32_t sampleStart = micros();

    vReal[i] = analogRead(AUDIO_PIN);
    vImag[i] = 0.0;

    while ((micros() - sampleStart) <
           samplingPeriodMicroseconds) {
      // Wait for the next sample time.
    }
  }
}

// =====================================================
// FFT CALCULATION
// =====================================================

void calculateSpectrum() {
  // Remove microphone DC offset.
  FFT.dcRemoval();

  // Apply Hamming window.
  FFT.windowing(
    FFTWindow::Hamming,
    FFTDirection::Forward
  );

  // Calculate FFT.
  FFT.compute(FFTDirection::Forward);

  // Convert complex values into magnitudes.
  FFT.complexToMagnitude();

  double frameMaximumDB = NOISE_FLOOR_DB;

  // Find strongest displayed frequency bin.
  for (uint8_t column = 0; column < 16; column++) {
    uint8_t bin = spectrumBins[column];

    double levelDB = magnitudeToDb(vReal[bin]);

    if (levelDB > frameMaximumDB) {
      frameMaximumDB = levelDB;
    }
  }

  // Calculate target upper gain limit.
  double targetTopDB = frameMaximumDB + PEAK_HEADROOM_DB;

  if (targetTopDB <
      NOISE_FLOOR_DB + MIN_DYNAMIC_RANGE_DB) {

    targetTopDB =
      NOISE_FLOOR_DB + MIN_DYNAMIC_RANGE_DB;
  }

  // Smooth automatic gain.
  autoGainTopDB =
    autoGainTopDB * (1.0 - AUTO_GAIN_SPEED) +
    targetTopDB * AUTO_GAIN_SPEED;

  // Calculate 16 display levels.
  for (uint8_t column = 0; column < 16; column++) {
    uint8_t bin = spectrumBins[column];

    double levelDB = magnitudeToDb(vReal[bin]);

    double normalized =
      (levelDB - NOISE_FLOOR_DB) /
      (autoGainTopDB - NOISE_FLOOR_DB);

    if (normalized < 0.0) {
      normalized = 0.0;
    }

    if (normalized > 1.0) {
      normalized = 1.0;
    }

    float targetLevel = normalized * 15.0;

    // Faster rise, slower fall.
    if (targetLevel > displayedLevel[column]) {
      displayedLevel[column] =
        displayedLevel[column] * (1.0 - BAR_ATTACK) +
        targetLevel * BAR_ATTACK;
    } else {
      displayedLevel[column] =
        displayedLevel[column] * (1.0 - BAR_RELEASE) +
        targetLevel * BAR_RELEASE;
    }
  }
}

// =====================================================
// CONVERT FFT MAGNITUDE TO DECIBEL-LIKE VALUE
// =====================================================

double magnitudeToDb(double magnitude) {
  return 20.0 * log10(magnitude + 1.0);
}

// =====================================================
// DRAW 16 SPECTRUM BARS
// =====================================================

void drawSpectrum() {
  for (uint8_t column = 0; column < 16; column++) {
    int level = round(displayedLevel[column]);

    level = constrain(level, 0, 15);

    if (level == 0) {
      lcd.setCursor(column, 0);
      lcd.print(' ');

      lcd.setCursor(column, 1);
      lcd.print(' ');
    }
    else if (level <= 8) {
      lcd.setCursor(column, 0);
      lcd.print(' ');

      lcd.setCursor(column, 1);
      lcd.write((uint8_t)(level - 1));
    }
    else {
      lcd.setCursor(column, 0);
      lcd.write((uint8_t)(level - 9));

      lcd.setCursor(column, 1);
      lcd.write((uint8_t)7);
    }
  }
}