Maker.io main logo

Build a Real 5-Band Parametric EQ with ESP32 + WM8960

20

2026-10-19 | By Mirko Pavleski

Share

License: General Public License

An audio equalizer (EQ) is an electronic device that allows control over the strength of different frequencies in the audio signal. Generally, there are two types of equalizers: graphic (or standard) and parametric equalizers. The main difference between these types is the degree of control over the sound: the graphic EQ gives you control only over the strength of the previously fixed frequencies, while the parametric one gives you complete freedom to choose which frequency you want to change and how wide the filter affects it.

kjlj

However, a parametric equalizer is much more complex to manufacture, much more expensive, and is therefore most often used in professional studios.

This time I will present you with a way to build a budget-friendly and relatively simple stereo parametric equalizer using an ESP32 microcontroller and a WM8960 Stereo CODEC (Coder-Decoder) Audio Module. The settings can be visually monitored on a Color display, which gives another professional feature to this advanced audio device.

First of all, I would like to present the function of the equalizer in the simplest way possible, without going into technical details. At the input of the audio system, we bring in so-called pink noise (that is, all audible frequencies together, with equal intensity), which is graphically represented by a straight line in the entire range.

;['][;

Now, if we place a microphone with a spectrum analyzer at the place where we should hear music, theoretically we should get the same almost straight line on the analyzer as on the system's input. However, most often, due to the nonlinear characteristics of the speakers, as well as the geometry of the room, at the place intended for listening, the spectrum analyzer shows a curved line, for example, with a shape like in the picture (the red line). Certain frequencies are amplified, and others are attenuated. To correct this, we need to "draw" a mirror curve of the original with the equalizer (blue line). Thus, the resulting curve at the listening position would be approximately flat, which is the ultimate goal for quality Hi-Fi listening. This previously mentioned symmetrical mirror curve can be approximately adjusted with a Graphic Equalizer, and ideally with a Parametric Equalizer.

jh

As I mentioned earlier, this device contains several components.

- ESP32 microcontroller board Dev1 30 pin

- WM8960 Audio Module

- ILI9341 320x240 LCD Display

- Rotary encoders

- And a few resistors

However, on this particular board, the 3.5 mm EARPHONE connector on this board is Stereo headphone output + microphone input. On the other channel, there is an onboard MEMS microphone. So it is intended for stereo microphone inputs, and we need line-in inputs. With a small hardware modification to the board, we can get these inputs. It is shown in the pictures below.

gyuiyg

vgjg

We need to remove the 2k2 SMD resistor, and then one input is directly on the connector. As for the other input where the MEMS microphone is originally connected, the indicated capacitor should be unsoldered, and only one contact should be soldered directly to the PCB, as in the picture. The free contact from the capacitor is the other input. Then, directly on the RCA connectors, we form a voltage divider with two resistors as shown in the schematic.

jholhu

With this, the hardware part of modifying the audio board is complete.

As you can see, the device I built is a tangle of wires, but this is merely a test unit intended to demonstrate functionality. The actual, final Hi-Fi device needs to be constructed using shielded and grounded components—particularly the display and the ESP32—to prevent interference with the audio. Additionally, a "star grounding" scheme should be implemented, and coaxial audio cables should be used for the inputs and outputs.

The image below shows a table regarding how the components are connected.

hkhjk

As for the code, it generally consists of two parts, namely a visual part that represents the current settings state and sends commands to the audio board, and the part that performs the audio processing, i.e. the Audio Codec Board. Unexpectedly, the second part of the code was much more complex than the graphical control part, considering that there is no library specifically for this purpose. To compile the code without errors, it is necessary to install the given libraries, especially TFT-eSPI, which is modified specifically for this configuration.

jioji

I should mention that the equalizer connects between the preamplifier and the power amplifier.

hjkghjk

First, let me explain all the functions. When turned on, the last set configuration appears on the display. In the bottom row of the screen, there are five buttons that select the five areas by clicking on the first rotating encoder. On the left side are the values ​​of the three parameters for the selected area.

-By rotating this encoder, the center frequency for the specific area is changed. With a long press, bypass is performed, i.e., a clean, unprocessed sound is sent to the output for comparison. With a repeated long click, we return to the equalized sound.

-By pressing the second rotating encoder, the speed of changing the curve is changed: normal and 2x speed. By rotating it, the Gain value is changed in the range from -15 to +15 dB. With a long click on the button of this encoder, the device is reset to the initial factory settings, i.e., an unprocessed audio signal to the outputs.

ghjghj

With a single click on the third rotary encoder, the way the curve is displayed changes between Single Band/ All Bands Show, and with its rotation, the Q factor of the curve changes. A long press on the third encoder enters a special PRESET mode. Specifically, I have reserved five slots for saving settings. "P1" appears on the screen, and rotating the encoder allows you to select a slot from P1 to P5. After making a selection, a short press loads the previously saved state, while a long press saves the current EQ state to the selected slot.

So with these three encoders, we can adjust any shape of the curve, which is almost impossible for a graphic equalizer. This equalizer also has the ability to detect clipping distortion caused by a too-strong input signal, which is manifested by the graph turning red at moments of clipping.

For example, if there is a 50Hz hum caused by the power supply, we can remove a very narrow area around that frequency, thus eliminating that defect.

hjikhu

And finally, a short conclusion: This project shows that an inexpensive ESP32 and WM8960 codec can become a surprisingly powerful real-time stereo audio processor. With five fully parametric bands, presets, bypass, and graphical control, it offers capabilities normally associated with much more expensive audio equipment.

Copy Code
// by mircemk, September 2026

#include <TFT_eSPI.h>
#include <math.h>
#include <Preferences.h>
#include <Wire.h>
#include <SparkFun_WM8960_Arduino_Library.h>
#include "driver/i2s.h"

TFT_eSPI tft = TFT_eSPI();

Preferences preferences;

// =====================================================
// NON-VOLATILE SETTINGS MEMORY
// =====================================================

bool settingsDirty = false;
unsigned long lastSettingsChange = 0;
const unsigned long SETTINGS_SAVE_DELAY = 1000;

// =====================================================
// GRAPH SPRITE
// =====================================================

TFT_eSprite graphSprite = TFT_eSprite(&tft);

// =====================================================
// ENCODER PINS
// =====================================================

// Encoder 1 - FREQUENCY
#define ENC1_A   34
#define ENC1_B   35
#define ENC1_SW  32

// Encoder 2 - GAIN
#define ENC2_A   36
#define ENC2_B   39
#define ENC2_SW  33

// Encoder 3 - Q FACTOR
#define ENC3_A   25
#define ENC3_B   26
#define ENC3_SW  27

// If direction is reversed,
// change 1 to -1
#define ENC1_DIR  -1
#define ENC2_DIR  -1
#define ENC3_DIR  -1

// =====================================================
// BAND DATA
// =====================================================

int selectedBand = 1;

// Separate Frequency / Gain / Q for every band
float bandFreq[5] =
{
  80.0,
  250.0,
  1250.0,
  5000.0,
  12000.0
};

float bandGain[5] =
{
  0.0,
  0.0,
  0.0,
  0.0,
  0.0
};

float bandQ[5] =
{
  1.00,
  1.00,
  1.40,
  1.00,
  1.00
};


// =====================================================
// REAL AUDIO ENGINE - WM8960 + 5-BAND STEREO DSP
// =====================================================
//
// Proven hardware / clock configuration:
// ESP32 classic WROOM-32
// ESP32 = I2S MASTER
// WM8960 = I2S SLAVE / PERIPHERAL
// 48 kHz / 16-bit stereo
// I2S_NUM_1
//
// IMPORTANT STARTUP RULE:
// WM8960 is configured completely FIRST.
// ESP32 I2S clocks are started LAST.
//
// LINE IN -> WM8960 ADC -> TXSDA -> ESP32
//          -> 5 real peaking biquads per channel
//          -> RXSDA -> WM8960 DAC -> output
// =====================================================

WM8960 codec;

// ---------- WM8960 I2C ----------
#define WM_SDA       21
#define WM_SCL       22

// ---------- WM8960 I2S ----------
#define I2S_BCLK     14
#define I2S_WS       13
#define I2S_DOUT     17      // ESP32 -> WM8960 RXSDA (DAC data)
#define I2S_DIN      19      // WM8960 TXSDA -> ESP32 (ADC data)
#define AUDIO_I2S    I2S_NUM_1

const uint32_t AUDIO_SAMPLE_RATE = 48000;
const int AUDIO_FRAMES = 240;

// 240 stereo frames = 960 bytes per audio block
int16_t audioBuffer[AUDIO_FRAMES * 2];

// No fixed digital attenuation in this integrated version.
// Later this can become a user/preamp/headroom control if needed.
const float DSP_PREAMP_GAIN = 1.0f;  // 0 dB

// =====================================================
// TEMPORARY CLIPPING-DEMO INPUT SENSITIVITY
// =====================================================
// TEST ONLY: +18 dB PGA gain makes it easy to provoke DSP clipping
// with a normal LINE IN source and demonstrate the red graph warning.
// For normal/final operation set this back to 0.0f if desired.
// SparkFun WM8960 library supports PGA gain from -17.25 dB to +30 dB.

const float LINE_INPUT_PGA_DB = 0.0f;

struct BiquadCoeff
{
  float b0;
  float b1;
  float b2;
  float a1;
  float a2;
};

struct BiquadState
{
  float z1;
  float z2;
};

// Same coefficients for L/R; independent states preserve stereo.
BiquadCoeff dspCoeff[5];
BiquadState dspStateCH0[5];
BiquadState dspStateCH1[5];

// UI -> audio task handoff.
// The audio task owns dspCoeff[] and filter states.
portMUX_TYPE dspSettingsMux = portMUX_INITIALIZER_UNLOCKED;

float pendingBandFreq[5];
float pendingBandGain[5];
float pendingBandQ[5];

volatile bool dspSettingsDirty = true;
volatile bool dspResetRequested = true;
volatile bool dspBypassMode = false;

TaskHandle_t audioTaskHandle = NULL;

// DSP output clipping latch. The audio task extends this timestamp
// whenever either processed stereo channel exceeds full scale.
// The main UI uses it to turn the graph red for 500 ms.
volatile unsigned long dspClipUntil = 0;

// Tracks whether the graph is currently being shown in CLIP-red mode,
// so redraw happens only on CLIP state transitions.
bool clipGraphShown = false;

// =====================================================
// FREQUENCY RANGE FOR EACH BAND
// Overlapping ranges
// =====================================================

const float BAND_FREQ_MIN[5] =
{
  20.0,    // BAND 1
  80.0,    // BAND 2
  250.0,   // BAND 3
  1000.0,  // BAND 4
  4000.0   // BAND 5
};

const float BAND_FREQ_MAX[5] =
{
  250.0,    // BAND 1
  1000.0,   // BAND 2
  4000.0,   // BAND 3
  10000.0,  // BAND 4
  20000.0   // BAND 5
};

// =====================================================
// MODES
// =====================================================

// SW2
bool speed2x = false;

// SW3
bool allBandsView = false;

// =====================================================
// 5 USER PRESETS - SW3 LONG PRESS MODE
// =====================================================
// NORMAL MODE:
//   Q encoder rotation = Q
//   SW3 short          = SINGLE / ALL graph
//   SW3 long           = enter PRESET MODE
//
// PRESET MODE:
//   Q encoder rotation = select P1..P5
//   SW3 short          = LOAD selected preset
//   SW3 long           = SAVE current EQ to selected preset
//
// Presets store ONLY the 15 sound parameters:
//   Frequency[5], Gain[5], Q[5]
// UI states (BYPASS, speed, ALL view, selected band) are not preset data.
// =====================================================

const int PRESET_COUNT = 5;
const unsigned long SW3_LONG_PRESS_MS = 2000;
const unsigned long SW3_DEBOUNCE_MS   = 30;
const unsigned long PRESET_MODE_TIMEOUT_MS = 8000;
const unsigned long PRESET_STATUS_MS       = 1000;

bool presetMode = false;
int presetSelectedSlot = 1;
int lastPresetSlot = 1;
unsigned long presetLastActivity = 0;

String presetStatusText = "";
unsigned long presetStatusUntil = 0;

// SW1 LONG PRESS (>2 sec)
// true = EQ bypass / flat 0 dB response
bool bypassMode = false;

// Long-press timing for SW1
const unsigned long SW1_LONG_PRESS_MS = 2000;
const unsigned long SW1_DEBOUNCE_MS   = 30;

// Long-press timing for SW2
const unsigned long SW2_LONG_PRESS_MS = 2000;
const unsigned long SW2_DEBOUNCE_MS   = 30;

// =====================================================
// COLOR DEFINITIONS
// EXACTLY FROM YOUR GRAPHIC FINAL CODE
// =====================================================

#define COL_BG        0x0000
#define COL_BUTTON    0x0000
#define COL_GRAPH_BG  0x0000

#define COL_GRID      0x8400
#define COL_BORDER    0x3D9F
#define COL_BORDER_G  0xFFFF

#define COL_CYAN      0x07FF
#define COL_YELLOW    0xFFE0
#define COL_GREEN     0x07E0
#define COL_MAGENTA   0xF81F
#define COL_RED       0xF800
#define COL_WHITE     0xFFFF
#define COL_GREY      0x9E79

// Current transient PRESET status color (LOADED / SAVED / EMPTY / CANCEL).
uint16_t presetStatusColor = COL_CYAN;

// =====================================================
// LAYOUT DIMENSIONS
// EXACTLY FROM YOUR GRAPHIC FINAL CODE
// =====================================================

const int TITLE_Y = 5;

// ---------- LEFT PANEL ----------
const int PANEL_X = 6;
const int PANEL_Y = 30;
const int PANEL_W = 66;
const int PANEL_H = 145;

// ---------- GRAPH ----------
const int GRAPH_X = 98;
const int GRAPH_Y = 30;
const int GRAPH_W = 214;
const int GRAPH_H = 145;

// ---------- GRAPH RANGE ----------
const float F_MIN  = 20.0;
const float F_MAX  = 20000.0;

const float DB_MIN = -15.0;
const float DB_MAX = 15.0;

// =====================================================
// ENCODER STRUCTURE
// =====================================================

struct EncoderState
{
  uint8_t pinA;
  uint8_t pinB;
  uint8_t pinSW;

  uint8_t lastAB;

  int8_t accumulator;

  bool lastButton;

  unsigned long lastButtonTime;
};

EncoderState enc1 =
{
  ENC1_A,
  ENC1_B,
  ENC1_SW,
  0,
  0,
  HIGH,
  0
};

EncoderState enc2 =
{
  ENC2_A,
  ENC2_B,
  ENC2_SW,
  0,
  0,
  HIGH,
  0
};

EncoderState enc3 =
{
  ENC3_A,
  ENC3_B,
  ENC3_SW,
  0,
  0,
  HIGH,
  0
};

// =====================================================
// ROTARY TRANSITION TABLE
// =====================================================

const int8_t encoderTable[16] =
{
   0, -1,  1,  0,
   1,  0,  0, -1,
  -1,  0,  0,  1,
   0,  1, -1,  0
};

// =====================================================
// BASIC HELPERS
// =====================================================

int currentBandIndex()
{
  return selectedBand - 1;
}

// =====================================================
// MAPPING
// =====================================================

int freqToX(float f)
{
  float p =
    (log10(f) - log10(F_MIN)) /
    (log10(F_MAX) - log10(F_MIN));

  return GRAPH_X +
         (int)(p * (GRAPH_W - 1));
}

int dbToY(float db)
{
  if (db > DB_MAX)
    db = DB_MAX;

  if (db < DB_MIN)
    db = DB_MIN;

  float p =
    (DB_MAX - db) /
    (DB_MAX - DB_MIN);

  return GRAPH_Y +
         (int)(p * (GRAPH_H - 1));
}

// =====================================================
// LOCAL SPRITE COORDINATES
// =====================================================

int freqToLocalX(float f)
{
  return freqToX(f) - GRAPH_X;
}

int dbToLocalY(float db)
{
  return dbToY(db) - GRAPH_Y;
}

// =====================================================
// CLIPPING INDICATOR STATE
// =====================================================

bool isDSPClippingActive()
{
  // BYPASS is true digital pass-through, so DSP clipping indication
  // is intentionally suppressed while BYPASS is active.
  if (bypassMode)
  {
    return false;
  }

  unsigned long until = dspClipUntil;
  unsigned long now = millis();

  // Wrap-safe timestamp comparison.
  return ((long)(until - now) > 0);
}

// =====================================================
// ONE BAND VISUAL CURVE
// =====================================================

float visualCurveBand(int band, float f)
{
  float freqHz =
    bandFreq[band];

  float gainDb =
    bandGain[band];

  float qFactor =
    bandQ[band];

  float d =
    log(f / freqHz);

  float width =
    0.62 / qFactor;

  return gainDb *
         exp(
           -(d * d) /
           (2.0 * width * width)
         );
}

// =====================================================
// DISPLAYED CURVE
//
// SINGLE mode:
// selected band only
//
// ALL mode:
// dB responses of all 5 bands are added
// =====================================================

float visualCurve(float f)
{
  // BYPASS:
  // Keep all stored Frequency / Gain / Q values unchanged,
  // but display a flat 0 dB response.
  // Later the real audio DSP will use the same bypassMode
  // to pass audio without EQ processing.
  if (bypassMode)
  {
    return 0.0;
  }

  if (!allBandsView)
  {
    return visualCurveBand(
      currentBandIndex(),
      f
    );
  }

  float totalDb = 0.0;

  for (int i = 0; i < 5; i++)
  {
    totalDb +=
      visualCurveBand(
        i,
        f
      );
  }

  return totalDb;
}

// =====================================================
// VALUE FORMATTING
// =====================================================

String formatFrequency(float f)
{
  if (f < 1000.0)
  {
    return String(
      (int)round(f)
    ) + " Hz";
  }

  if (f < 10000.0)
  {
    return String(
      f / 1000.0,
      2
    ) + "kHz";
  }

  return String(
    f / 1000.0,
    1
  ) + "kHz";
}

String formatGain(float gain)
{
  String s = "";

  if (gain > 0.0)
    s += "+";

  if (fabs(gain) >= 10.0)
    s += String(gain, 0);
  else
    s += String(gain, 1);

  s += " dB";

  return s;
}

// =====================================================
// HEADER
// UNCHANGED GRAPHICS
// =====================================================

void drawHeader()
{
  tft.setTextDatum(TC_DATUM);

  tft.setTextColor(
    COL_CYAN,
    COL_BG
  );

  tft.drawString(
    "5-BAND PARAMETRIC EQUALIZER",
    160,
    TITLE_Y,
    2
  );

  tft.drawFastHLine(
    10,
    TITLE_Y + 7,
    40,
    COL_CYAN
  );

  tft.drawFastHLine(
    270,
    TITLE_Y + 7,
    40,
    COL_CYAN
  );
}

// =====================================================
// LEFT PANEL
// SAME GRAPHICS - DYNAMIC VALUES
// =====================================================

void drawLeftPanel()
{
  tft.fillRoundRect(
    PANEL_X,
    PANEL_Y,
    PANEL_W,
    PANEL_H,
    6,
    COL_BG
  );

  tft.drawRoundRect(
    PANEL_X,
    PANEL_Y,
    PANEL_W,
    PANEL_H,
    6,
    COL_BORDER
  );

  int x =
    PANEL_X + 6;

  int b =
    currentBandIndex();

  tft.setTextDatum(
    TL_DATUM
  );

  // BAND
  tft.setTextColor(
    COL_YELLOW,
    COL_BG
  );

  tft.drawString(
    "BAND " + String(selectedBand),
    x,
    PANEL_Y + 5,
    2
  );

  // FREQ
  tft.setTextColor(
    COL_WHITE,
    COL_BG
  );

  tft.drawString(
    "FREQ",
    x,
    PANEL_Y + 33,
    1
  );

  tft.setTextColor(
    COL_CYAN,
    COL_BG
  );

  tft.drawString(
    formatFrequency(
      bandFreq[b]
    ),
    x,
    PANEL_Y + 45,
    2
  );

  // GAIN
  tft.setTextColor(
    COL_WHITE,
    COL_BG
  );

  tft.drawString(
    "GAIN",
    x,
    PANEL_Y + 71,
    1
  );

  tft.setTextColor(
    COL_GREEN,
    COL_BG
  );

  tft.drawString(
    formatGain(
      bandGain[b]
    ),
    x,
    PANEL_Y + 83,
    2
  );

  // Q FACTOR
  tft.setTextColor(
    COL_WHITE,
    COL_BG
  );

  tft.drawString(
    "Q FACTOR",
    x,
    PANEL_Y + 109,
    1
  );

  tft.setTextColor(
    COL_MAGENTA,
    COL_BG
  );

  tft.drawString(
    String(
      bandQ[b],
      2
    ),
    x,
    PANEL_Y + 121,
    2
  );
}

// =====================================================
// SPEED INDICATOR
//
// Small white 1x / 2x directly below left panel
// =====================================================

void drawSpeedIndicator()
{
  // Clear indicator area
  tft.fillRect(
    PANEL_X,
    PANEL_Y + PANEL_H + 1,
    PANEL_W,
    14,
    COL_BG
  );

  int indicatorY =
    PANEL_Y + PANEL_H + 7;

  // ---------------------------------------------
  // B = BYPASS MODE ACTIVE
  // ---------------------------------------------

  if (bypassMode)
  {
    tft.setTextDatum(TC_DATUM);

    tft.setTextColor(
      COL_WHITE,
      COL_BG
    );

    tft.drawString(
      "B",
      PANEL_X + 9,
      PANEL_Y + PANEL_H + 3,
      1
    );
  }

  // ---------------------------------------------
  // RED DOT = ALL BANDS VIEW ACTIVE
  // ---------------------------------------------

  if (allBandsView)
  {
    tft.fillCircle(
      PANEL_X + 21,
      indicatorY,
      2,
      TFT_RED
    );
  }

  // ---------------------------------------------
  // 1x / 2x
  // ---------------------------------------------

  tft.setTextDatum(TC_DATUM);

  tft.setTextColor(
    COL_WHITE,
    COL_BG
  );

  tft.drawString(
    speed2x ? "2x" : "1x",
    PANEL_X + 43,
    PANEL_Y + PANEL_H + 3,
    1
  );
}

// =====================================================
// STATIC GRAPH LABELS
//
// These do NOT need redrawing during encoder movement
// =====================================================

void drawGraphLabels()
{
  // ---------- dB labels ----------

  int dbValues[] =
  {
     15,
     10,
      5,
      0,
     -5,
    -10,
    -15
  };

  for (int i = 0; i < 7; i++)
  {
    int y =
      dbToY(
        dbValues[i]
      );

    tft.setTextDatum(
      MR_DATUM
    );

    tft.setTextColor(
      (dbValues[i] == 0)
        ? COL_WHITE
        : COL_GREY,
      COL_BG
    );

    String s =
      (dbValues[i] > 0)
        ? "+" + String(dbValues[i])
        : String(dbValues[i]);

    tft.drawString(
      s,
      GRAPH_X - 4,
      y,
      1
    );
  }

  // ---------- Frequency labels ----------

  const float fMarks[] =
  {
    20,
    50,
    100,
    200,
    500,
    1000,
    2000,
    5000,
    10000,
    20000
  };

  const char* fLabels[] =
  {
    "20",
    "50",
    "100",
    "200",
    "500",
    "1k",
    "2k",
    "5k",
    "10k",
    "20k"
  };

  for (int i = 0; i < 10; i++)
  {
    int x =
      freqToX(
        fMarks[i]
      );

    tft.setTextColor(
      COL_GREY,
      COL_BG
    );

    if (i == 3)
    {
      tft.setTextDatum(
        TC_DATUM
      );

      tft.drawString(
        fLabels[i],
        x + 2,
        GRAPH_Y + GRAPH_H + 3,
        1
      );
    }

    else if (i == 8)
    {
      tft.setTextDatum(
        TC_DATUM
      );

      tft.drawString(
        fLabels[i],
        x - 1,
        GRAPH_Y + GRAPH_H + 3,
        1
      );
    }

    else if (i == 9)
    {
      tft.setTextDatum(
        TR_DATUM
      );

      tft.drawString(
        fLabels[i],
        x + 4,
        GRAPH_Y + GRAPH_H + 3,
        1
      );
    }

    else if (i == 0)
    {
      tft.setTextDatum(
        TL_DATUM
      );

      tft.drawString(
        fLabels[i],
        x,
        GRAPH_Y + GRAPH_H + 3,
        1
      );
    }

    else
    {
      tft.setTextDatum(
        TC_DATUM
      );

      tft.drawString(
        fLabels[i],
        x,
        GRAPH_Y + GRAPH_H + 3,
        1
      );
    }
  }
}

// =====================================================
// PRESET STATUS OVERLAY INSIDE GRAPH
// Top-center, intentionally small and unobtrusive.
// =====================================================

bool presetStatusActive()
{
  if (presetStatusText.length() == 0)
    return false;

  return ((long)(presetStatusUntil - millis()) > 0);
}

void setPresetStatus(
  const String &text,
  uint16_t color
)
{
  presetStatusText = text;
  presetStatusColor = color;
  presetStatusUntil = millis() + PRESET_STATUS_MS;
}

void clearPresetStatus()
{
  presetStatusText = "";
  presetStatusUntil = 0;
}

void drawPresetOverlayOnSprite()
{
  String text = "";
  uint16_t textColor = COL_CYAN;

  // A transient result message has priority over PRESET selection text.
  if (presetStatusActive())
  {
    text = presetStatusText;
    textColor = presetStatusColor;
  }
  else if (presetMode)
  {
    text = String("PRESET P") + String(presetSelectedSlot);
    textColor = COL_CYAN;
  }
  else
  {
    return;
  }

  // Fixed compact black patch prevents the EQ curve/grid from
  // making the status difficult to read.
  const int boxW = 96;
  const int boxH = 14;
  const int boxX = (GRAPH_W - boxW) / 2;
  const int boxY = 3;

  graphSprite.fillRoundRect(
    boxX,
    boxY,
    boxW,
    boxH,
    3,
    COL_GRAPH_BG
  );

  graphSprite.setTextDatum(TC_DATUM);
  graphSprite.setTextColor(
    textColor,
    COL_GRAPH_BG
  );

  graphSprite.drawString(
    text,
    GRAPH_W / 2,
    boxY + 3,
    1
  );
}

// =====================================================
// GRAPH RENDERING INTO RAM SPRITE
// =====================================================

void renderGraph()
{
  graphSprite.fillSprite(
    COL_GRAPH_BG
  );

  // During a latched DSP clipping event only the graph rendering changes:
  // cyan curve/fill -> red curve/fill. Layout and all controls stay untouched.
  bool clipActive = isDSPClippingActive();

  uint16_t graphLineColor =
    clipActive ? COL_RED : COL_CYAN;

  const int bottomY =
    GRAPH_H - 2;

  // ===================================================
  // CURVE + SAME GRADIENT
  // ===================================================

  int oldX = 1;

  int oldY =
    dbToLocalY(
      visualCurve(F_MIN)
    );

  for (
    int x = 1;
    x < GRAPH_W - 1;
    x++
  )
  {
    float p =
      (float)x /
      (float)(GRAPH_W - 1);

    float logF =
      log10(F_MIN) +
      p *
      (
        log10(F_MAX) -
        log10(F_MIN)
      );

    float f =
      pow(
        10.0,
        logF
      );

    float db =
      visualCurve(f);

    int y =
      dbToLocalY(db);

    // -----------------------------------------------
    // SAME GRADIENT AS GRAPHIC FINAL
    // -----------------------------------------------

    if (y < bottomY)
    {
      int fillHeight =
        bottomY - y;

      for (
        int h = 1;
        h < fillHeight;
        h++
      )
      {
        float ratio =
          1.0 -
          (
            (float)h /
            (float)fillHeight
          );

        uint8_t val =
          (uint8_t)
          (
            ratio *
            128.0
          );

        uint16_t gradColor;

        if (clipActive)
        {
          // Red clipping gradient
          gradColor =
            tft.color565(
              val,
              0,
              0
            );
        }
        else
        {
          // Original cyan gradient
          gradColor =
            tft.color565(
              0,
              val,
              val
            );
        }

        graphSprite.drawPixel(
          x,
          y + h,
          gradColor
        );
      }
    }

    // Main cyan line
    graphSprite.drawLine(
      oldX,
      oldY,
      x,
      y,
      graphLineColor
    );

    // Second pixel line
    graphSprite.drawLine(
      oldX,
      oldY + 1,
      x,
      y + 1,
      graphLineColor
    );

    oldX = x;
    oldY = y;
  }

  // ===================================================
  // dB GRID
  // ===================================================

  int dbValues[] =
  {
     15,
     10,
      5,
      0,
     -5,
    -10,
    -15
  };

  for (int i = 0; i < 7; i++)
  {
    int y =
      dbToLocalY(
        dbValues[i]
      );

    for (
      int px = 1;
      px < GRAPH_W - 1;
      px += 3
    )
    {
      graphSprite.drawPixel(
        px,
        y,
        COL_GRID
      );
    }
  }

  // ===================================================
  // FREQUENCY GRID
  // ===================================================

  const float fMarks[] =
  {
    20,
    50,
    100,
    200,
    500,
    1000,
    2000,
    5000,
    10000,
    20000
  };

  for (int i = 0; i < 10; i++)
  {
    int x =
      freqToLocalX(
        fMarks[i]
      );

    for (
      int py = 1;
      py < GRAPH_H - 1;
      py += 3
    )
    {
      graphSprite.drawPixel(
        x,
        py,
        COL_GRID
      );
    }
  }

  // ===================================================
  // WHITE BORDER
  // ===================================================

  graphSprite.drawRect(
    0,
    0,
    GRAPH_W,
    GRAPH_H,
    clipActive ? COL_RED : COL_BORDER_G
  );

  // ===================================================
  // PRESET STATUS / SELECTION OVERLAY
  // ===================================================

  drawPresetOverlayOnSprite();

  // ===================================================
  // PUSH COMPLETE GRAPH AT ONCE
  // ===================================================

  graphSprite.pushSprite(
    GRAPH_X,
    GRAPH_Y
  );
}

// =====================================================
// MINI CURVES
// UNCHANGED
// =====================================================

void drawMiniCurve(
  int x,
  int y,
  uint16_t color
)
{
  int oldX =
    x;

  int oldY =
    y + 8;

  for (int i = 0; i < 38; i++)
  {
    float d =
      ((float)i - 19.0) /
      5.5;

    float response =
      exp(
        -(d * d) /
        2.0
      );

    int py =
      y + 8 -
      (int)(
        response * 6.0
      );

    tft.drawLine(
      oldX,
      oldY,
      x + i,
      py,
      color
    );

    oldX =
      x + i;

    oldY =
      py;
  }
}

// =====================================================
// BAND BUTTONS
// UNCHANGED GRAPHICS
// =====================================================

void drawBandButtons()
{
  const int y   = 193;
  const int h   = 40;
  const int w   = 58;
  const int gap = 4;

  for (int i = 0; i < 5; i++)
  {
    int x =
      6 +
      i * (w + gap);

    bool active =
      (i + 1 == selectedBand);

    uint16_t bg =
      COL_BG;

    uint16_t border =
      active
        ? COL_YELLOW
        : COL_BORDER;

    uint16_t text =
      active
        ? COL_YELLOW
        : COL_WHITE;

    uint16_t curve =
      active
        ? COL_YELLOW
        : COL_CYAN;

    tft.fillRoundRect(
      x,
      y,
      w,
      h,
      6,
      bg
    );

    tft.drawRoundRect(
      x,
      y,
      w,
      h,
      6,
      border
    );

    drawMiniCurve(
      x + 10,
      y + 4,
      curve
    );

    tft.setTextDatum(
      TC_DATUM
    );

    tft.setTextColor(
      text,
      bg
    );

    String s =
      "BAND " +
      String(i + 1);

    tft.drawString(
      s,
      x + w / 2,
      y + 24,
      1
    );
  }
}

// =====================================================
// ENCODER READ
// =====================================================

int readEncoder(
  EncoderState &enc
)
{
  uint8_t currentAB =
    (
      digitalRead(enc.pinA)
      << 1
    ) |
    digitalRead(enc.pinB);

  uint8_t index =
    (
      enc.lastAB << 2
    ) |
    currentAB;

  int8_t movement =
    encoderTable[index];

  enc.lastAB =
    currentAB;

  enc.accumulator +=
    movement;

  // Four transitions per detent
  if (enc.accumulator >= 4)
  {
    enc.accumulator = 0;
    return +1;
  }

  if (enc.accumulator <= -4)
  {
    enc.accumulator = 0;
    return -1;
  }

  return 0;
}

// =====================================================
// BUTTON DEBOUNCE
// =====================================================

bool buttonPressed(
  EncoderState &enc
)
{
  bool state =
    digitalRead(
      enc.pinSW
    );

  bool pressed =
    false;

  unsigned long now =
    millis();

  if (
    enc.lastButton == HIGH &&
    state == LOW &&
    now - enc.lastButtonTime > 160
  )
  {
    pressed = true;

    enc.lastButtonTime =
      now;
  }

  enc.lastButton =
    state;

  return pressed;
}

// =====================================================
// SPEED MULTIPLIER
// =====================================================

int speedMultiplier()
{
  return speed2x ? 2 : 1;
}

// =====================================================
/// =====================================================
// FREQUENCY CHANGE
// =====================================================

void changeFrequency(int direction)
{
  int b = currentBandIndex();

  int mult = speedMultiplier();

  // 1x = 1/48 octave
  // 2x = 1/24 octave

  float factor =
    pow(
      2.0,
      ((float)direction * mult) / 48.0
    );

  bandFreq[b] *= factor;

  if (bandFreq[b] < BAND_FREQ_MIN[b])
    bandFreq[b] = BAND_FREQ_MIN[b];

  if (bandFreq[b] > BAND_FREQ_MAX[b])
    bandFreq[b] = BAND_FREQ_MAX[b];
}


// =====================================================
// GAIN CHANGE
// =====================================================

void changeGain(int direction)
{
  int b = currentBandIndex();

  // 1x = 0.25 dB
  // 2x = 0.50 dB

  bandGain[b] +=
    direction *
    0.25 *
    speedMultiplier();

  if (bandGain[b] > 15.0)
    bandGain[b] = 15.0;

  if (bandGain[b] < -15.0)
    bandGain[b] = -15.0;
}


// =====================================================
// Q CHANGE
// =====================================================

void changeQ(int direction)
{
  int b = currentBandIndex();

  // 1x = 0.05
  // 2x = 0.10

  bandQ[b] +=
    direction *
    0.05 *
    speedMultiplier();

  if (bandQ[b] > 10.0)
    bandQ[b] = 10.0;

  if (bandQ[b] < 0.20)
    bandQ[b] = 0.20;
}

// =====================================================
// NEXT BAND
// =====================================================

void nextBand()
{
  selectedBand++;

  if (selectedBand > 5)
    selectedBand = 1;
}

// =====================================================
// 5 PRESET SLOTS IN NVS
// =====================================================

const uint32_t PRESET_MAGIC = 0x45515031UL; // "EQP1" marker

struct EQPresetStorage
{
  uint32_t magic;
  float freq[5];
  float gain[5];
  float q[5];
};

void makePresetKey(int slot, char *key, size_t keySize)
{
  snprintf(
    key,
    keySize,
    "p%d",
    slot
  );
}

bool savePresetSlot(int slot)
{
  if (slot < 1 || slot > PRESET_COUNT)
    return false;

  EQPresetStorage data;
  data.magic = PRESET_MAGIC;

  for (int i = 0; i < 5; i++)
  {
    data.freq[i] = bandFreq[i];
    data.gain[i] = bandGain[i];
    data.q[i] = bandQ[i];
  }

  char key[8];
  makePresetKey(slot, key, sizeof(key));

  preferences.begin("peqpres", false);

  size_t written =
    preferences.putBytes(
      key,
      &data,
      sizeof(data)
    );

  preferences.end();

  return (written == sizeof(data));
}

bool loadPresetSlot(int slot)
{
  if (slot < 1 || slot > PRESET_COUNT)
    return false;

  char key[8];
  makePresetKey(slot, key, sizeof(key));

  EQPresetStorage data;

  preferences.begin("peqpres", true);

  size_t storedLength =
    preferences.getBytesLength(key);

  if (storedLength != sizeof(data))
  {
    preferences.end();
    return false;
  }

  size_t readLength =
    preferences.getBytes(
      key,
      &data,
      sizeof(data)
    );

  preferences.end();

  if (
    readLength != sizeof(data) ||
    data.magic != PRESET_MAGIC
  )
  {
    return false;
  }

  for (int i = 0; i < 5; i++)
  {
    bandFreq[i] = data.freq[i];
    bandGain[i] = data.gain[i];
    bandQ[i] = data.q[i];

    // Defensive validation in case NVS ever contains old/corrupt data.
    if (bandFreq[i] < BAND_FREQ_MIN[i])
      bandFreq[i] = BAND_FREQ_MIN[i];

    if (bandFreq[i] > BAND_FREQ_MAX[i])
      bandFreq[i] = BAND_FREQ_MAX[i];

    if (bandGain[i] < -15.0f)
      bandGain[i] = -15.0f;

    if (bandGain[i] > 15.0f)
      bandGain[i] = 15.0f;

    if (bandQ[i] < 0.20f)
      bandQ[i] = 0.20f;

    if (bandQ[i] > 10.0f)
      bandQ[i] = 10.0f;
  }

  return true;
}

// =====================================================
// SAVE / LOAD SETTINGS
// Added only for power-off memory
// =====================================================

void saveSettings()
{
  preferences.begin("peq", false);

  preferences.putBytes(
    "freq",
    bandFreq,
    sizeof(bandFreq)
  );

  preferences.putBytes(
    "gain",
    bandGain,
    sizeof(bandGain)
  );

  preferences.putBytes(
    "q",
    bandQ,
    sizeof(bandQ)
  );

  preferences.putInt(
    "band",
    selectedBand
  );

  preferences.putBool(
    "speed2x",
    speed2x
  );

  preferences.putBool(
    "allBands",
    allBandsView
  );

  preferences.putBool(
    "bypass",
    bypassMode
  );

  preferences.putInt(
    "lastPreset",
    lastPresetSlot
  );

  preferences.putBool(
    "valid",
    true
  );

  preferences.end();
}

void loadSettings()
{
  preferences.begin("peq", true);

  bool valid =
    preferences.getBool(
      "valid",
      false
    );

  if (valid)
  {
    if (
      preferences.getBytesLength("freq")
      == sizeof(bandFreq)
    )
    {
      preferences.getBytes(
        "freq",
        bandFreq,
        sizeof(bandFreq)
      );
    }

    if (
      preferences.getBytesLength("gain")
      == sizeof(bandGain)
    )
    {
      preferences.getBytes(
        "gain",
        bandGain,
        sizeof(bandGain)
      );
    }

    if (
      preferences.getBytesLength("q")
      == sizeof(bandQ)
    )
    {
      preferences.getBytes(
        "q",
        bandQ,
        sizeof(bandQ)
      );
    }

    selectedBand =
      preferences.getInt(
        "band",
        selectedBand
      );

    if (
      selectedBand < 1 ||
      selectedBand > 5
    )
    {
      selectedBand = 1;
    }

    speed2x =
      preferences.getBool(
        "speed2x",
        speed2x
      );

    allBandsView =
      preferences.getBool(
        "allBands",
        allBandsView
      );

    bypassMode =
      preferences.getBool(
        "bypass",
        bypassMode
      );

    lastPresetSlot =
      preferences.getInt(
        "lastPreset",
        lastPresetSlot
      );

    if (
      lastPresetSlot < 1 ||
      lastPresetSlot > PRESET_COUNT
    )
    {
      lastPresetSlot = 1;
    }
  }

  preferences.end();

  presetSelectedSlot = lastPresetSlot;

  // Ensure previously saved frequencies also respect
  // the new per-band overlapping limits.
  for (int i = 0; i < 5; i++)
  {
    if (bandFreq[i] < BAND_FREQ_MIN[i])
      bandFreq[i] = BAND_FREQ_MIN[i];

    if (bandFreq[i] > BAND_FREQ_MAX[i])
      bandFreq[i] = BAND_FREQ_MAX[i];
  }
}

void markSettingsChanged()
{
  settingsDirty = true;
  lastSettingsChange = millis();
}

void saveSettingsIfNeeded()
{
  if (
    settingsDirty &&
    millis() - lastSettingsChange >= SETTINGS_SAVE_DELAY
  )
  {
    saveSettings();
    settingsDirty = false;
  }
}

// =====================================================
// SW1 SHORT / LONG PRESS HANDLER
//
// Short press  : next BAND
// Long press   : BYPASS ON / OFF
//
// The long press does NOT overwrite Frequency / Gain / Q.
// It only forces a flat 0 dB response, so a second long
// press restores the exact previous EQ settings.
// =====================================================

void handleSW1(
  bool &bandChanged,
  bool &bypassChanged
)
{
  static bool lastRawState = HIGH;
  static bool stableState = HIGH;
  static unsigned long lastRawChange = 0;
  static unsigned long pressStart = 0;
  static bool longPressHandled = false;

  unsigned long now = millis();

  bool rawState =
    digitalRead(ENC1_SW);

  // Raw-state change -> start debounce timer
  if (rawState != lastRawState)
  {
    lastRawState = rawState;
    lastRawChange = now;
  }

  // Accept a new stable state only after debounce time
  if (
    (now - lastRawChange >= SW1_DEBOUNCE_MS) &&
    (stableState != rawState)
  )
  {
    stableState = rawState;

    // Button became pressed
    if (stableState == LOW)
    {
      pressStart = now;
      longPressHandled = false;
    }

    // Button became released
    else
    {
      // If no long press was executed, this was a short press
      if (!longPressHandled)
      {
        nextBand();

        bandChanged = true;

        markSettingsChanged();
      }
    }
  }

  // Long press while the button is still held
  if (
    stableState == LOW &&
    !longPressHandled &&
    (now - pressStart >= SW1_LONG_PRESS_MS)
  )
  {
    bypassMode =
      !bypassMode;

    longPressHandled = true;

    bypassChanged = true;

    markSettingsChanged();
  }
}

// =====================================================
// FACTORY DEFAULT
//
// Restores the original startup state:
// BAND 1
// Frequencies: 80, 250, 1250, 5000, 12000 Hz
// Gains      : all 0 dB
// Q          : 1.00, 1.00, 1.40, 1.00, 1.00
// Speed      : 1x
// All Bands  : OFF
// Bypass     : OFF
// =====================================================

void restoreFactoryDefaults()
{
  selectedBand = 1;

  bandFreq[0] = 80.0;
  bandFreq[1] = 250.0;
  bandFreq[2] = 1250.0;
  bandFreq[3] = 5000.0;
  bandFreq[4] = 12000.0;

  bandGain[0] = 0.0;
  bandGain[1] = 0.0;
  bandGain[2] = 0.0;
  bandGain[3] = 0.0;
  bandGain[4] = 0.0;

  bandQ[0] = 1.00;
  bandQ[1] = 1.00;
  bandQ[2] = 1.40;
  bandQ[3] = 1.00;
  bandQ[4] = 1.00;

  speed2x = false;
  allBandsView = false;
  bypassMode = false;

  // Factory reset should be stored immediately.
  saveSettings();

  settingsDirty = false;
}

// =====================================================
// SW2 SHORT / LONG PRESS HANDLER
//
// Short press  : toggle 1x / 2x
// Long press   : FACTORY DEFAULT
// =====================================================

void handleSW2(
  bool &factoryResetChanged
)
{
  static bool lastRawState = HIGH;
  static bool stableState = HIGH;
  static unsigned long lastRawChange = 0;
  static unsigned long pressStart = 0;
  static bool longPressHandled = false;

  unsigned long now = millis();

  bool rawState =
    digitalRead(ENC2_SW);

  // Raw-state change -> start debounce timer
  if (rawState != lastRawState)
  {
    lastRawState = rawState;
    lastRawChange = now;
  }

  // Accept new stable state after debounce
  if (
    (now - lastRawChange >= SW2_DEBOUNCE_MS) &&
    (stableState != rawState)
  )
  {
    stableState = rawState;

    // Button became pressed
    if (stableState == LOW)
    {
      pressStart = now;
      longPressHandled = false;
    }

    // Button became released
    else
    {
      // Short press only if long press was not executed
      if (!longPressHandled)
      {
        speed2x =
          !speed2x;

        drawSpeedIndicator();

        markSettingsChanged();
      }
    }
  }

  // Long press while button is still held
  if (
    stableState == LOW &&
    !longPressHandled &&
    (now - pressStart >= SW2_LONG_PRESS_MS)
  )
  {
    restoreFactoryDefaults();

    longPressHandled = true;

    factoryResetChanged = true;
  }
}


// =====================================================
// SW3 SHORT / LONG PRESS + PRESET MODE
//
// NORMAL MODE:
// Short press : SINGLE / ALL graph
// Long press  : enter PRESET MODE
//
// PRESET MODE:
// Q encoder   : select P1..P5
// Short press : LOAD selected preset
// Long press  : SAVE current EQ into selected preset
// =====================================================

void handleSW3(
  bool &graphViewChanged,
  bool &presetVisualChanged,
  bool &presetLoadedChanged
)
{
  static bool lastRawState = HIGH;
  static bool stableState = HIGH;
  static unsigned long lastRawChange = 0;
  static unsigned long pressStart = 0;
  static bool longPressHandled = false;
  static bool pressStartedInPresetMode = false;

  unsigned long now = millis();

  bool rawState =
    digitalRead(ENC3_SW);

  if (rawState != lastRawState)
  {
    lastRawState = rawState;
    lastRawChange = now;
  }

  if (
    (now - lastRawChange >= SW3_DEBOUNCE_MS) &&
    (stableState != rawState)
  )
  {
    stableState = rawState;

    // Button pressed
    if (stableState == LOW)
    {
      pressStart = now;
      longPressHandled = false;
      pressStartedInPresetMode = presetMode;

      if (presetMode)
      {
        presetLastActivity = now;
      }
    }

    // Button released -> short click if no long action happened
    else
    {
      if (!longPressHandled)
      {
        if (pressStartedInPresetMode)
        {
          // -------------------------------
          // PRESET MODE SHORT = LOAD
          // -------------------------------

          if (loadPresetSlot(presetSelectedSlot))
          {
            lastPresetSlot = presetSelectedSlot;
            presetMode = false;

            setPresetStatus(
              String("P") + String(presetSelectedSlot) + " LOADED",
              COL_GREEN
            );

            presetLoadedChanged = true;
            presetVisualChanged = true;

            // Loaded values become the current power-off state too.
            markSettingsChanged();
          }
          else
          {
            // Empty slot: stay in PRESET MODE so another slot can be selected.
            setPresetStatus(
              String("P") + String(presetSelectedSlot) + " EMPTY",
              COL_YELLOW
            );

            presetLastActivity = now;
            presetVisualChanged = true;
          }
        }
        else
        {
          // -------------------------------
          // NORMAL MODE SHORT = SINGLE/ALL
          // -------------------------------

          allBandsView =
            !allBandsView;

          graphViewChanged = true;

          markSettingsChanged();
        }
      }
    }
  }

  // Long press while still held
  if (
    stableState == LOW &&
    !longPressHandled &&
    (now - pressStart >= SW3_LONG_PRESS_MS)
  )
  {
    longPressHandled = true;

    if (pressStartedInPresetMode)
    {
      // -----------------------------------
      // PRESET MODE LONG = SAVE
      // -----------------------------------

      if (savePresetSlot(presetSelectedSlot))
      {
        lastPresetSlot = presetSelectedSlot;
        presetMode = false;

        setPresetStatus(
          String("P") + String(presetSelectedSlot) + " SAVED",
          COL_GREEN
        );

        markSettingsChanged();
      }
      else
      {
        setPresetStatus(
          "SAVE ERROR",
          COL_RED
        );

        presetMode = false;
      }

      presetVisualChanged = true;
    }
    else
    {
      // -----------------------------------
      // NORMAL MODE LONG = ENTER PRESETS
      // -----------------------------------

      presetMode = true;
      presetSelectedSlot = lastPresetSlot;
      presetLastActivity = now;

      clearPresetStatus();

      presetVisualChanged = true;
    }
  }
}

// PRESET mode auto-cancels after inactivity.
// Transient LOADED/SAVED/EMPTY/CANCEL messages are also expired here.
void updatePresetTimers(
  bool &presetVisualChanged
)
{
  unsigned long now = millis();

  if (
    presetMode &&
    (now - presetLastActivity >= PRESET_MODE_TIMEOUT_MS)
  )
  {
    presetMode = false;

    setPresetStatus(
      "PRESET CANCEL",
      COL_GREY
    );

    presetVisualChanged = true;
  }

  if (
    presetStatusText.length() > 0 &&
    !presetStatusActive()
  )
  {
    clearPresetStatus();
    presetVisualChanged = true;
  }
}

// =====================================================
// REAL AUDIO DSP HELPERS
// =====================================================

void resetDSPStates()
{
  for (int i = 0; i < 5; i++)
  {
    dspStateCH0[i].z1 = 0.0f;
    dspStateCH0[i].z2 = 0.0f;

    dspStateCH1[i].z1 = 0.0f;
    dspStateCH1[i].z2 = 0.0f;
  }
}

// Copy the UI values into a small protected staging area.
// Coefficients are calculated later inside the dedicated audio task.
void queueDSPSettingsFromUI(bool resetStates)
{
  portENTER_CRITICAL(&dspSettingsMux);

  for (int i = 0; i < 5; i++)
  {
    pendingBandFreq[i] = bandFreq[i];
    pendingBandGain[i] = bandGain[i];
    pendingBandQ[i] = bandQ[i];
  }

  dspBypassMode = bypassMode;
  dspSettingsDirty = true;

  if (resetStates)
  {
    dspResetRequested = true;
  }

  portEXIT_CRITICAL(&dspSettingsMux);
}

void queueDSPBypassFromUI()
{
  portENTER_CRITICAL(&dspSettingsMux);

  dspBypassMode = bypassMode;

  // Clearing old biquad memory prevents a stale transient
  // when leaving BYPASS and returning to EQ processing.
  dspResetRequested = true;

  portEXIT_CRITICAL(&dspSettingsMux);
}

void calculateDSPPeakingEQ(
  int band,
  float freqHz,
  float gainDb,
  float q
)
{
  // At exactly 0 dB use an exact identity section.
  // This keeps a flat band mathematically transparent.
  if (fabsf(gainDb) < 0.00001f)
  {
    dspCoeff[band].b0 = 1.0f;
    dspCoeff[band].b1 = 0.0f;
    dspCoeff[band].b2 = 0.0f;
    dspCoeff[band].a1 = 0.0f;
    dspCoeff[band].a2 = 0.0f;
    return;
  }

  float A =
    powf(
      10.0f,
      gainDb / 40.0f
    );

  float omega =
    2.0f *
    PI *
    freqHz /
    (float)AUDIO_SAMPLE_RATE;

  float sinOmega = sinf(omega);
  float cosOmega = cosf(omega);

  float alpha =
    sinOmega /
    (2.0f * q);

  float b0 =
    1.0f + alpha * A;

  float b1 =
    -2.0f * cosOmega;

  float b2 =
    1.0f - alpha * A;

  float a0 =
    1.0f + alpha / A;

  float a1 =
    -2.0f * cosOmega;

  float a2 =
    1.0f - alpha / A;

  dspCoeff[band].b0 = b0 / a0;
  dspCoeff[band].b1 = b1 / a0;
  dspCoeff[band].b2 = b2 / a0;
  dspCoeff[band].a1 = a1 / a0;
  dspCoeff[band].a2 = a2 / a0;
}

void applyPendingDSPSettings()
{
  bool updateCoefficients = false;
  bool resetStates = false;

  float localFreq[5];
  float localGain[5];
  float localQ[5];

  portENTER_CRITICAL(&dspSettingsMux);

  if (dspSettingsDirty)
  {
    for (int i = 0; i < 5; i++)
    {
      localFreq[i] = pendingBandFreq[i];
      localGain[i] = pendingBandGain[i];
      localQ[i] = pendingBandQ[i];
    }

    dspSettingsDirty = false;
    updateCoefficients = true;
  }

  if (dspResetRequested)
  {
    dspResetRequested = false;
    resetStates = true;
  }

  portEXIT_CRITICAL(&dspSettingsMux);

  if (updateCoefficients)
  {
    for (int i = 0; i < 5; i++)
    {
      calculateDSPPeakingEQ(
        i,
        localFreq[i],
        localGain[i],
        localQ[i]
      );
    }
  }

  if (resetStates)
  {
    resetDSPStates();
  }
}

float processDSPBiquad(
  float x,
  const BiquadCoeff &c,
  BiquadState &state
)
{
  // Transposed Direct Form II
  float y =
    c.b0 * x +
    state.z1;

  state.z1 =
    c.b1 * x -
    c.a1 * y +
    state.z2;

  state.z2 =
    c.b2 * x -
    c.a2 * y;

  return y;
}

float processDSPFiveBands(
  float x,
  BiquadState state[]
)
{
  float y = x;

  for (int band = 0; band < 5; band++)
  {
    y =
      processDSPBiquad(
        y,
        dspCoeff[band],
        state[band]
      );
  }

  return y;
}

int16_t dspFloatToInt16(float x)
{
  if (x >= 1.0f)
    return 32767;

  if (x <= -1.0f)
    return -32768;

  return (int16_t)(
    x * 32767.0f
  );
}

void processRealStereoDSP(size_t bytes)
{
  // Real BYPASS = untouched ADC samples go directly back to DAC.
  if (dspBypassMode)
  {
    return;
  }

  int sampleCount =
    bytes / sizeof(int16_t);

  bool blockClipped = false;

  for (int i = 0; i + 1 < sampleCount; i += 2)
  {
    float x0 =
      ((float)audioBuffer[i] / 32768.0f) *
      DSP_PREAMP_GAIN;

    float x1 =
      ((float)audioBuffer[i + 1] / 32768.0f) *
      DSP_PREAMP_GAIN;

    float y0 =
      processDSPFiveBands(
        x0,
        dspStateCH0
      );

    float y1 =
      processDSPFiveBands(
        x1,
        dspStateCH1
      );

    if (
      y0 >= 1.0f ||
      y0 <= -1.0f ||
      y1 >= 1.0f ||
      y1 <= -1.0f
    )
    {
      blockClipped = true;
    }

    audioBuffer[i] =
      dspFloatToInt16(y0);

    audioBuffer[i + 1] =
      dspFloatToInt16(y1);
  }

  if (blockClipped)
  {
    // 500 ms latch reserved for the later red graph indicator.
    dspClipUntil = millis() + 500;
  }
}

// =====================================================
// WM8960 CONFIGURATION
// Proven working configuration from TEST 3C / 4A / 4B
// =====================================================

#define CODEC_CHECK(label, command)                  \
  do                                                 \
  {                                                  \
    bool ok = false;                                 \
    int attempt = 0;                                 \
                                                     \
    for (attempt = 1; attempt <= 3; attempt++)      \
    {                                                \
      if (command)                                   \
      {                                              \
        ok = true;                                   \
        break;                                       \
      }                                              \
                                                     \
      delay(30);                                     \
    }                                                \
                                                     \
    Serial.print(label);                             \
    Serial.print(" : ");                            \
                                                     \
    if (ok)                                          \
    {                                                \
      Serial.print("OK");                           \
                                                     \
      if (attempt > 1)                              \
      {                                              \
        Serial.print(" attempt ");                  \
        Serial.print(attempt);                       \
      }                                              \
                                                     \
      Serial.println();                              \
    }                                                \
    else                                             \
    {                                                \
      Serial.println("FAILED");                     \
      return false;                                  \
    }                                                \
                                                     \
    delay(10);                                       \
  } while (0)

bool setupAudioCodec()
{
  Serial.println();
  Serial.println("======================================");
  Serial.println("WM8960: CONFIGURE CODEC FIRST");
  Serial.println("I2S CLOCKS ARE STILL STOPPED");
  Serial.println("======================================");

  Wire.begin(
    WM_SDA,
    WM_SCL
  );

  // Proven stable by the earlier 500/500 I2C test.
  Wire.setClock(25000);

  delay(1000);

  bool found = false;

  for (int attempt = 1; attempt <= 10; attempt++)
  {
    Serial.print("codec.begin attempt ");
    Serial.print(attempt);
    Serial.print(" : ");

    if (codec.begin())
    {
      Serial.println("OK");
      found = true;
      break;
    }

    Serial.println("FAILED");
    delay(100);
  }

  if (!found)
    return false;

  delay(200);

  CODEC_CHECK(
    "VREF",
    codec.enableVREF()
  );

  CODEC_CHECK(
    "VMID",
    codec.enableVMID()
  );

  delay(200);

  // 24 MHz onboard oscillator -> proven 48 kHz clock setup
  CODEC_CHECK(
    "PLL prescale /2",
    codec.setPLLPRESCALE(
      WM8960_PLLPRESCALE_DIV_2
    )
  );

  CODEC_CHECK(
    "PLL fractional",
    codec.setSMD(
      WM8960_PLL_MODE_FRACTIONAL
    )
  );

  CODEC_CHECK(
    "PLL N=8",
    codec.setPLLN(8)
  );

  CODEC_CHECK(
    "PLL K",
    codec.setPLLK(
      0x31,
      0x26,
      0xE8
    )
  );

  CODEC_CHECK(
    "PLL ON",
    codec.enablePLL()
  );

  delay(100);

  CODEC_CHECK(
    "Clock source PLL",
    codec.setCLKSEL(
      WM8960_CLKSEL_PLL
    )
  );

  CODEC_CHECK(
    "SYSCLK /2",
    codec.setSYSCLKDIV(
      WM8960_SYSCLK_DIV_BY_2
    )
  );

  CODEC_CHECK(
    "ADC divider 256fs",
    codec.setADCDIV(0)
  );

  CODEC_CHECK(
    "DAC divider 256fs",
    codec.setDACDIV(0)
  );

  CODEC_CHECK(
    "16-bit I2S",
    codec.setWL(
      WM8960_WL_16BIT
    )
  );

  CODEC_CHECK(
    "WM8960 SLAVE",
    codec.enablePeripheralMode()
  );

  CODEC_CHECK(
    "LR polarity normal",
    codec.setLRP(
      WM8960_LR_POLARITY_NORMAL
    )
  );

  CODEC_CHECK(
    "ADC L/R swap OFF",
    codec.setALRSWAP(
      WM8960_ALRSWAP_NORMAL
    )
  );

  CODEC_CHECK(
    "ADC/DAC common WS",
    codec.setALRCGPIO()
  );

  // ---------- LINE INPUT ----------
  CODEC_CHECK(
    "LEFT PGA ON",
    codec.enableLMIC()
  );

  CODEC_CHECK(
    "RIGHT PGA ON",
    codec.enableRMIC()
  );

  CODEC_CHECK(
    "LINPUT1 -> LEFT PGA",
    codec.connectLMN1()
  );

  CODEC_CHECK(
    "RINPUT1 -> RIGHT PGA",
    codec.connectRMN1()
  );

  CODEC_CHECK(
    "LEFT PGA UNMUTE",
    codec.disableLINMUTE()
  );

  CODEC_CHECK(
    "RIGHT PGA UNMUTE",
    codec.disableRINMUTE()
  );

  CODEC_CHECK(
    "LEFT PGA DEMO GAIN",
    codec.setLINVOLDB(LINE_INPUT_PGA_DB)
  );

  CODEC_CHECK(
    "RIGHT PGA DEMO GAIN",
    codec.setRINVOLDB(LINE_INPUT_PGA_DB)
  );

  CODEC_CHECK(
    "LEFT MIC BOOST 0dB",
    codec.setLMICBOOST(
      WM8960_MIC_BOOST_GAIN_0DB
    )
  );

  CODEC_CHECK(
    "RIGHT MIC BOOST 0dB",
    codec.setRMICBOOST(
      WM8960_MIC_BOOST_GAIN_0DB
    )
  );

  CODEC_CHECK(
    "LEFT PGA -> ADC mixer",
    codec.connectLMIC2B()
  );

  CODEC_CHECK(
    "RIGHT PGA -> ADC mixer",
    codec.connectRMIC2B()
  );

  CODEC_CHECK(
    "LEFT analog input ON",
    codec.enableAINL()
  );

  CODEC_CHECK(
    "RIGHT analog input ON",
    codec.enableAINR()
  );

  // ---------- ADC ----------
  CODEC_CHECK(
    "LEFT ADC ON",
    codec.enableAdcLeft()
  );

  CODEC_CHECK(
    "RIGHT ADC ON",
    codec.enableAdcRight()
  );

  CODEC_CHECK(
    "LEFT ADC 0dB",
    codec.setAdcLeftDigitalVolumeDB(0.0)
  );

  CODEC_CHECK(
    "RIGHT ADC 0dB",
    codec.setAdcRightDigitalVolumeDB(0.0)
  );

  // ---------- DAC ----------
  CODEC_CHECK(
    "DAC LEFT ON",
    codec.enableDacLeft()
  );

  CODEC_CHECK(
    "DAC RIGHT ON",
    codec.enableDacRight()
  );

  CODEC_CHECK(
    "DAC LEFT 0dB",
    codec.setDacLeftDigitalVolume(255)
  );

  CODEC_CHECK(
    "DAC RIGHT 0dB",
    codec.setDacRightDigitalVolume(255)
  );

  CODEC_CHECK(
    "Digital loopback OFF",
    codec.disableLoopBack()
  );

  CODEC_CHECK(
    "LEFT DAC -> LEFT MIX",
    codec.enableLD2LO()
  );

  CODEC_CHECK(
    "RIGHT DAC -> RIGHT MIX",
    codec.enableRD2RO()
  );

  CODEC_CHECK(
    "LEFT OUTPUT MIX ON",
    codec.enableLOMIX()
  );

  CODEC_CHECK(
    "RIGHT OUTPUT MIX ON",
    codec.enableROMIX()
  );

  CODEC_CHECK(
    "LEFT analog bypass OFF",
    codec.disableLB2LO()
  );

  CODEC_CHECK(
    "RIGHT analog bypass OFF",
    codec.disableRB2RO()
  );

  CODEC_CHECK(
    "LEFT HEADPHONE ON",
    codec.enableLeftHeadphone()
  );

  CODEC_CHECK(
    "RIGHT HEADPHONE ON",
    codec.enableRightHeadphone()
  );

  CODEC_CHECK(
    "HEADPHONE 0dB",
    codec.setHeadphoneVolumeDB(0.0)
  );

  CODEC_CHECK(
    "DAC UNMUTE",
    codec.disableDacMute()
  );

  delay(300);

  Serial.println("WM8960 CONFIG COMPLETE.");
  Serial.println();

  return true;
}

bool setupAudioI2S()
{
  Serial.println();
  Serial.println("======================================");
  Serial.println("START ESP32 FULL DUPLEX I2S LAST");
  Serial.println("======================================");

  i2s_config_t cfg = {};

  cfg.mode =
    (i2s_mode_t)(
      I2S_MODE_MASTER |
      I2S_MODE_TX |
      I2S_MODE_RX
    );

  cfg.sample_rate =
    AUDIO_SAMPLE_RATE;

  cfg.bits_per_sample =
    I2S_BITS_PER_SAMPLE_16BIT;

  cfg.channel_format =
    I2S_CHANNEL_FMT_RIGHT_LEFT;

  cfg.communication_format =
    I2S_COMM_FORMAT_STAND_I2S;

  cfg.intr_alloc_flags =
    ESP_INTR_FLAG_LEVEL1;

  cfg.dma_buf_count = 8;
  cfg.dma_buf_len = AUDIO_FRAMES;

  cfg.use_apll = false;
  cfg.tx_desc_auto_clear = true;
  cfg.fixed_mclk = 0;

  esp_err_t err =
    i2s_driver_install(
      AUDIO_I2S,
      &cfg,
      0,
      NULL
    );

  Serial.print("i2s_driver_install = ");
  Serial.println(err);

  if (err != ESP_OK)
    return false;

  i2s_pin_config_t pins = {};

  pins.mck_io_num =
    I2S_PIN_NO_CHANGE;

  pins.bck_io_num =
    I2S_BCLK;

  pins.ws_io_num =
    I2S_WS;

  pins.data_out_num =
    I2S_DOUT;

  pins.data_in_num =
    I2S_DIN;

  err =
    i2s_set_pin(
      AUDIO_I2S,
      &pins
    );

  Serial.print("i2s_set_pin = ");
  Serial.println(err);

  if (err != ESP_OK)
    return false;

  i2s_zero_dma_buffer(
    AUDIO_I2S
  );

  delay(200);

  Serial.println("FULL DUPLEX I2S READY.");
  Serial.println();

  return true;
}

// =====================================================
// DEDICATED AUDIO TASK
//
// Runs on Core 0 so TFT redraws / encoders / NVS in the
// normal Arduino loop cannot starve the real-time audio path.
// =====================================================

void audioTask(void *parameter)
{
  applyPendingDSPSettings();

  for (;;)
  {
    applyPendingDSPSettings();

    size_t bytesRead = 0;

    esp_err_t readErr =
      i2s_read(
        AUDIO_I2S,
        audioBuffer,
        sizeof(audioBuffer),
        &bytesRead,
        portMAX_DELAY
      );

    if (
      readErr == ESP_OK &&
      bytesRead > 0
    )
    {
      processRealStereoDSP(
        bytesRead
      );

      size_t bytesWritten = 0;

      i2s_write(
        AUDIO_I2S,
        audioBuffer,
        bytesRead,
        &bytesWritten,
        portMAX_DELAY
      );
    }
  }
}

// =====================================================
// SETUP
// =====================================================

void setup()
{
  Serial.begin(115200);
  delay(500);

  Serial.println();
  Serial.println("======================================");
  Serial.println("5-BAND PARAMETRIC EQ + REAL AUDIO DSP");
  Serial.println("UI V1.4 + WM8960 + 5 BIQUADS / CHANNEL + PRESETS");
  Serial.println("======================================");

  // ===================================================
  // ENCODER INPUTS
  // ===================================================

  // GPIO34/35/36/39:
  // external 10k pullups already on hardware

  pinMode(
    ENC1_A,
    INPUT
  );

  pinMode(
    ENC1_B,
    INPUT
  );

  pinMode(
    ENC2_A,
    INPUT
  );

  pinMode(
    ENC2_B,
    INPUT
  );

  pinMode(
    ENC3_A,
    INPUT
  );

  pinMode(
    ENC3_B,
    INPUT
  );

  // Buttons to GND
  pinMode(
    ENC1_SW,
    INPUT_PULLUP
  );

  pinMode(
    ENC2_SW,
    INPUT_PULLUP
  );

  pinMode(
    ENC3_SW,
    INPUT_PULLUP
  );

  // ===================================================
  // INITIAL ENCODER STATES
  // ===================================================

  enc1.lastAB =
    (
      digitalRead(ENC1_A)
      << 1
    ) |
    digitalRead(ENC1_B);

  enc2.lastAB =
    (
      digitalRead(ENC2_A)
      << 1
    ) |
    digitalRead(ENC2_B);

  enc3.lastAB =
    (
      digitalRead(ENC3_A)
      << 1
    ) |
    digitalRead(ENC3_B);

  // ===================================================
  // TFT
  // ===================================================

  tft.init();
  tft.setRotation(1);

  // ===================================================
  // CREATE GRAPH SPRITE
  // ~62 kB
  // ===================================================

  graphSprite.setColorDepth(
    16
  );

  graphSprite.createSprite(
    GRAPH_W,
    GRAPH_H
  );

  // ===================================================
  // LOAD LAST SAVED SETTINGS
  // ===================================================

  loadSettings();

  // ===================================================
  // INITIAL SCREEN
  // ===================================================

  tft.fillScreen(
    COL_BG
  );

  drawHeader();

  drawLeftPanel();

  drawGraphLabels();

  renderGraph();

  drawSpeedIndicator();

  drawBandButtons();

  // ===================================================
  // INITIAL REAL DSP SETTINGS
  // ===================================================

  queueDSPSettingsFromUI(true);

  // ===================================================
  // AUDIO STARTUP
  // WM8960 FIRST, I2S CLOCKS LAST
  // ===================================================

  if (!setupAudioCodec())
  {
    Serial.println("AUDIO CODEC SETUP FAILED");

    while (1)
      delay(1000);
  }

  if (!setupAudioI2S())
  {
    Serial.println("AUDIO I2S SETUP FAILED");

    while (1)
      delay(1000);
  }

  // Dedicated real-time audio processing on Core 0.
  BaseType_t taskResult =
    xTaskCreatePinnedToCore(
      audioTask,
      "AudioDSP",
      8192,
      NULL,
      3,
      &audioTaskHandle,
      0
    );

  if (taskResult != pdPASS)
  {
    Serial.println("AUDIO TASK CREATE FAILED");

    while (1)
      delay(1000);
  }

  Serial.println("REAL 5-BAND STEREO DSP RUNNING.");
  Serial.print("LINE INPUT PGA (CLIP DEMO) = +");
  Serial.print(LINE_INPUT_PGA_DB, 1);
  Serial.println(" dB");
  Serial.println("DSP CLIP WARNING = graph turns RED for 500 ms");
  Serial.println("SW3 LONG = PRESET MODE");
  Serial.println("PRESET MODE: Q rotate=P1..P5, SW3 short=LOAD, SW3 long=SAVE");
  Serial.println();
}

// =====================================================
// LOOP
// =====================================================

void loop()
{
  bool parametersChanged =
    false;

  bool bandChanged =
    false;

  bool bypassChanged =
    false;

  bool factoryResetChanged =
    false;

  bool graphViewChanged =
    false;

  bool presetVisualChanged =
    false;

  bool presetLoadedChanged =
    false;

  // Expire PRESET mode / transient preset messages.
  updatePresetTimers(
    presetVisualChanged
  );

  // ===================================================
  // ROTARY 1 - FREQUENCY
  // ===================================================

  int e1 =
    readEncoder(enc1);

  if (e1 != 0 && !bypassMode)
  {
    changeFrequency(
      e1 * ENC1_DIR
    );

    parametersChanged =
      true;

    markSettingsChanged();
  }

  // ===================================================
  // ROTARY 2 - GAIN
  // ===================================================

  int e2 =
    readEncoder(enc2);

  if (e2 != 0 && !bypassMode)
  {
    changeGain(
      e2 * ENC2_DIR
    );

    parametersChanged =
      true;

    markSettingsChanged();
  }

  // ===================================================
  // ROTARY 3 - Q
  // ===================================================

  int e3 =
    readEncoder(enc3);

  if (e3 != 0)
  {
    if (presetMode)
    {
      // In PRESET MODE the Q encoder temporarily selects P1..P5.
      int direction =
        e3 * ENC3_DIR;

      presetSelectedSlot += direction;

      if (presetSelectedSlot > PRESET_COUNT)
        presetSelectedSlot = 1;

      if (presetSelectedSlot < 1)
        presetSelectedSlot = PRESET_COUNT;

      presetLastActivity = millis();

      // If an EMPTY message was being shown, immediately return
      // to the live PRESET Pn selection display.
      clearPresetStatus();

      presetVisualChanged = true;
    }
    else if (!bypassMode)
    {
      changeQ(
        e3 * ENC3_DIR
      );

      parametersChanged =
        true;

      markSettingsChanged();
    }
  }

  // Send changed Frequency / Gain / Q values to the
  // real audio task immediately, independently of TFT redraw.
  if (parametersChanged)
  {
    queueDSPSettingsFromUI(false);
  }

  // ===================================================
  // SW1
  // SHORT PRESS = NEXT BAND
  // LONG PRESS  = BYPASS ON / OFF
  // ===================================================

  handleSW1(
    bandChanged,
    bypassChanged
  );

  if (bypassChanged)
  {
    // The existing UI BYPASS now controls the real DSP path.
    queueDSPBypassFromUI();
  }

  // ===================================================
  // SW2
  // SHORT PRESS = TOGGLE 1x / 2x
  // LONG PRESS  = FACTORY DEFAULT
  // ===================================================

  handleSW2(
    factoryResetChanged
  );

  if (factoryResetChanged)
  {
    // Factory defaults must update the real coefficients and bypass state too.
    queueDSPSettingsFromUI(true);
  }

  // ===================================================
  // SW3
  // NORMAL : SHORT = SINGLE/ALL, LONG = PRESET MODE
  // PRESET : SHORT = LOAD,       LONG = SAVE
  // ===================================================

  handleSW3(
    graphViewChanged,
    presetVisualChanged,
    presetLoadedChanged
  );

  // A loaded preset changes all 15 real EQ parameters.
  if (presetLoadedChanged)
  {
    queueDSPSettingsFromUI(true);

    drawLeftPanel();
    drawBandButtons();
  }

  // Normal-mode short SW3 still performs exactly the old
  // SINGLE / ALL graph function.
  if (graphViewChanged)
  {
    renderGraph();

    // Update red ALL-BANDS indicator
    drawSpeedIndicator();
  }

  // PRESET selection/status text lives inside the graph.
  if (presetVisualChanged)
  {
    renderGraph();
  }

  // ===================================================
  // FACTORY DEFAULT CHANGE
  // ===================================================

  if (factoryResetChanged)
  {
    drawLeftPanel();

    renderGraph();

    drawSpeedIndicator();

    drawBandButtons();
  }

  // ===================================================
  // BYPASS CHANGE
  // ===================================================

  if (bypassChanged)
  {
    // BYPASS ON  -> flat 0 dB graph
    // BYPASS OFF -> restore the exact previously set EQ curve
    renderGraph();

    // Update B bypass indicator
    drawSpeedIndicator();
  }

  // ===================================================
  // PARAMETER CHANGE
  // ===================================================

  if (parametersChanged)
  {
    // Values in left panel
    drawLeftPanel();

    // Graph:
    // selected band in SINGLE mode,
    // overall response in ALL mode
    renderGraph();
  }

  // ===================================================
  // BAND SELECTION CHANGE
  // ===================================================

  if (bandChanged)
  {
    drawLeftPanel();

    drawBandButtons();

    // In SINGLE mode graph must switch
    // to the newly selected band.
    //
    // In ALL mode the combined curve is
    // unchanged just by selecting another band.

    if (!allBandsView)
    {
      renderGraph();
    }
  }

  // ===================================================
  // REAL-TIME DSP CLIPPING GRAPH INDICATOR
  // ===================================================
  // The audio task latches dspClipUntil for 500 ms whenever
  // either processed channel exceeds digital full scale.
  // Redraw only when CLIP state changes, avoiding continuous TFT work.

  bool clipNow =
    isDSPClippingActive();

  if (clipNow != clipGraphShown)
  {
    clipGraphShown = clipNow;
    renderGraph();
  }

  // Save 1 second after the last change
  saveSettingsIfNeeded();
}
Have questions or comments? Continue the conversation on TechForum, DigiKey's online community and technical resource.