From 82cb939e7939985180a5d5c08dc2cf677a55ccb9 Mon Sep 17 00:00:00 2001 From: Will Tatam Date: Sun, 7 Jun 2026 11:06:53 +0100 Subject: [PATCH 1/5] Add msgeq7 usermod: software MSGEQ7 emulation with optional hardware chip support Implements a drop-in replacement for the audioreactive usermod that uses seven biquad bandpass IIR filters at the classic MSGEQ7 center frequencies (63/160/400/1k/2.5k/6.25k/16k Hz) instead of FFT. Produces the identical um_data_t 8-slot structure so all existing audio-reactive effects work without modification (registers as USERMOD_ID_AUDIOREACTIVE). Software backend (default): - I2S/ADC mic capture via audio_source.h (copied from audioreactive, supports INMP441, ES7243, SPH0645, ES8388, PDM, ADC) - esp-dsp dsps_biquad_f32_ae32/aes3 SIMD bandpass filters at 44100 Hz - Asymmetric peak-hold envelope (15 ms attack / 80 ms decay) - Log compression matching real MSGEQ7 chip output characteristic - 7-to-16 channel log-frequency interpolation (weights precomputed at setup) - FFT_MajorPeak via parabolic interpolation between top-2 bands - Beat/samplePeak detection from sub-bass rate-of-rise - FreeRTOS task on Core 0, no external library dependency Hardware backend (optional): - Physical MSGEQ7 chip via strobe/reset/ADC1 GPIO pins - Standard pulse-and-read protocol, ~50 Hz update rate Also includes readme.md (wiring, settings, effect caveats) and tools/sweep_analyze.py for serial-log band response validation. --- usermods/msgeq7/audio_source.h | 794 ++++++++++++++++++++++ usermods/msgeq7/library.json | 4 + usermods/msgeq7/msgeq7.cpp | 895 +++++++++++++++++++++++++ usermods/msgeq7/readme.md | 179 +++++ usermods/msgeq7/tools/sweep_analyze.py | 138 ++++ 5 files changed, 2010 insertions(+) create mode 100644 usermods/msgeq7/audio_source.h create mode 100644 usermods/msgeq7/library.json create mode 100644 usermods/msgeq7/msgeq7.cpp create mode 100644 usermods/msgeq7/readme.md create mode 100644 usermods/msgeq7/tools/sweep_analyze.py diff --git a/usermods/msgeq7/audio_source.h b/usermods/msgeq7/audio_source.h new file mode 100644 index 0000000000..7f9baf46ca --- /dev/null +++ b/usermods/msgeq7/audio_source.h @@ -0,0 +1,794 @@ +// Copied verbatim from usermods/audioreactive/audio_source.h. +// If that file changes, this copy should be updated to match. +#pragma once +#ifdef ARDUINO_ARCH_ESP32 +#include "wled.h" +#include +#include +#include // needed for SPH0465 timing workaround (classic ESP32) +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(4, 4, 0) +#if !defined(CONFIG_IDF_TARGET_ESP32S2) && !defined(CONFIG_IDF_TARGET_ESP32S3) && !defined(CONFIG_IDF_TARGET_ESP32C3) +#include +#include +#endif +// type of i2s_config_t.SampleRate was changed from "int" to "unsigned" in IDF 4.4.x +#define SRate_t uint32_t +#else +#define SRate_t int +#endif + +//#include +//#include +//#include +//#include + +// see https://docs.espressif.com/projects/esp-idf/en/latest/esp32s3/hw-reference/chip-series-comparison.html#related-documents +// and https://docs.espressif.com/projects/esp-idf/en/latest/esp32s3/api-reference/peripherals/i2s.html#overview-of-all-modes +#if defined(CONFIG_IDF_TARGET_ESP32C2) || defined(CONFIG_IDF_TARGET_ESP32C5) || defined(CONFIG_IDF_TARGET_ESP32C6) || defined(CONFIG_IDF_TARGET_ESP32H2) || defined(ESP8266) || defined(ESP8265) + // there are two things in these MCUs that could lead to problems with audio processing: + // * no floating point hardware (FPU) support - FFT uses float calculations. If done in software, a strong slow-down can be expected (between 8x and 20x) + // * single core, so FFT task might slow down other things like LED updates + #if !defined(SOC_I2S_NUM) || (SOC_I2S_NUM < 1) + #error This audio reactive usermod does not support ESP32-C2 or ESP32-C3. + #else + #warning This audio reactive usermod does not support ESP32-C2 and ESP32-C3. + #endif +#endif + +/* ToDo: remove. ES7243 is controlled via compiler defines + Until this configuration is moved to the webinterface +*/ + +// if you have problems to get your microphone work on the left channel, uncomment the following line +//#define I2S_USE_RIGHT_CHANNEL // (experimental) define this to use right channel (digital mics only) + +// Uncomment the line below to utilize ADC1 _exclusively_ for I2S sound input. +// benefit: analog mic inputs will be sampled contiously -> better response times and less "glitches" +// WARNING: this option WILL lock-up your device in case that any other analogRead() operation is performed; +// for example if you want to read "analog buttons" +//#define I2S_GRAB_ADC1_COMPLETELY // (experimental) continuously sample analog ADC microphone. WARNING will cause analogRead() lock-up + +// data type requested from the I2S driver - currently we always use 32bit +//#define I2S_USE_16BIT_SAMPLES // (experimental) define this to request 16bit - more efficient but possibly less compatible + +#ifdef I2S_USE_16BIT_SAMPLES +#define I2S_SAMPLE_RESOLUTION I2S_BITS_PER_SAMPLE_16BIT +#define I2S_datatype int16_t +#define I2S_unsigned_datatype uint16_t +#define I2S_data_size I2S_BITS_PER_CHAN_16BIT +#undef I2S_SAMPLE_DOWNSCALE_TO_16BIT +#else +#define I2S_SAMPLE_RESOLUTION I2S_BITS_PER_SAMPLE_32BIT +//#define I2S_SAMPLE_RESOLUTION I2S_BITS_PER_SAMPLE_24BIT +#define I2S_datatype int32_t +#define I2S_unsigned_datatype uint32_t +#define I2S_data_size I2S_BITS_PER_CHAN_32BIT +#define I2S_SAMPLE_DOWNSCALE_TO_16BIT +#endif + +/* There are several (confusing) options in IDF 4.4.x: + * I2S_CHANNEL_FMT_RIGHT_LEFT, I2S_CHANNEL_FMT_ALL_RIGHT and I2S_CHANNEL_FMT_ALL_LEFT stands for stereo mode, which means two channels will transport different data. + * I2S_CHANNEL_FMT_ONLY_RIGHT and I2S_CHANNEL_FMT_ONLY_LEFT they are mono mode, both channels will only transport same data. + * I2S_CHANNEL_FMT_MULTIPLE means TDM channels, up to 16 channel will available, and they are stereo as default. + * if you want to receive two channels, one is the actual data from microphone and another channel is suppose to receive 0, it's different data in two channels, you need to choose I2S_CHANNEL_FMT_RIGHT_LEFT in this case. +*/ + +#if (ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(4, 4, 0)) && (ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(4, 5, 0)) +// espressif bug: only_left has no sound, left and right are swapped +// https://github.com/espressif/esp-idf/issues/9635 I2S mic not working since 4.4 (IDFGH-8138) +// https://github.com/espressif/esp-idf/issues/8538 I2S channel selection issue? (IDFGH-6918) +// https://github.com/espressif/esp-idf/issues/6625 I2S: left/right channels are swapped for read (IDFGH-4826) +#ifdef I2S_USE_RIGHT_CHANNEL +#define I2S_MIC_CHANNEL I2S_CHANNEL_FMT_ONLY_LEFT +#define I2S_MIC_CHANNEL_TEXT "right channel only (work-around swapped channel bug in IDF 4.4)." +#define I2S_PDM_MIC_CHANNEL I2S_CHANNEL_FMT_ONLY_RIGHT +#define I2S_PDM_MIC_CHANNEL_TEXT "right channel only" +#else +//#define I2S_MIC_CHANNEL I2S_CHANNEL_FMT_ALL_LEFT +//#define I2S_MIC_CHANNEL I2S_CHANNEL_FMT_RIGHT_LEFT +#define I2S_MIC_CHANNEL I2S_CHANNEL_FMT_ONLY_RIGHT +#define I2S_MIC_CHANNEL_TEXT "left channel only (work-around swapped channel bug in IDF 4.4)." +#define I2S_PDM_MIC_CHANNEL I2S_CHANNEL_FMT_ONLY_LEFT +#define I2S_PDM_MIC_CHANNEL_TEXT "left channel only." +#endif + +#else +// not swapped +#ifdef I2S_USE_RIGHT_CHANNEL +#define I2S_MIC_CHANNEL I2S_CHANNEL_FMT_ONLY_RIGHT +#define I2S_MIC_CHANNEL_TEXT "right channel only." +#else +#define I2S_MIC_CHANNEL I2S_CHANNEL_FMT_ONLY_LEFT +#define I2S_MIC_CHANNEL_TEXT "left channel only." +#endif +#define I2S_PDM_MIC_CHANNEL I2S_MIC_CHANNEL +#define I2S_PDM_MIC_CHANNEL_TEXT I2S_MIC_CHANNEL_TEXT + +#endif + + +/* Interface class + AudioSource serves as base class for all microphone types + This enables accessing all microphones with one single interface + which simplifies the caller code +*/ +class AudioSource { + public: + /* All public methods are virtual, so they can be overridden + Everything but the destructor is also removed, to make sure each mic + Implementation provides its version of this function + */ + virtual ~AudioSource() {}; + + /* Initialize + This function needs to take care of anything that needs to be done + before samples can be obtained from the microphone. + */ + virtual void initialize(int8_t = I2S_PIN_NO_CHANGE, int8_t = I2S_PIN_NO_CHANGE, int8_t = I2S_PIN_NO_CHANGE, int8_t = I2S_PIN_NO_CHANGE) = 0; + + /* Deinitialize + Release all resources and deactivate any functionality that is used + by this microphone + */ + virtual void deinitialize() = 0; + + /* getSamples + Read num_samples from the microphone, and store them in the provided + buffer + */ + virtual void getSamples(FFTsampleType *buffer, uint16_t num_samples) = 0; + + /* check if the audio source driver was initialized successfully */ + virtual bool isInitialized(void) {return(_initialized);} + + /* identify Audiosource type - I2S-ADC or I2S-digital */ + typedef enum{Type_unknown=0, Type_I2SAdc=1, Type_I2SDigital=2} AudioSourceType; + virtual AudioSourceType getType(void) {return(Type_I2SDigital);} // default is "I2S digital source" - ADC type overrides this method + + protected: + /* Post-process audio sample - currently on needed for I2SAdcSource*/ + virtual I2S_datatype postProcessSample(I2S_datatype sample_in) {return(sample_in);} // default method can be overriden by instances (ADC) that need sample postprocessing + + // Private constructor, to make sure it is not callable except from derived classes + AudioSource(SRate_t sampleRate, int blockSize, float sampleScale) : + _sampleRate(sampleRate), + _blockSize(blockSize), + _initialized(false), + _sampleScale(sampleScale) + {}; + + SRate_t _sampleRate; // Microphone sampling rate + int _blockSize; // I2S block size + bool _initialized; // Gets set to true if initialization is successful + float _sampleScale; // pre-scaling factor for I2S samples +}; + +/* Basic I2S microphone source + All functions are marked virtual, so derived classes can replace them +*/ +class I2SSource : public AudioSource { + public: + I2SSource(SRate_t sampleRate, int blockSize, float sampleScale = 1.0f) : + AudioSource(sampleRate, blockSize, sampleScale) { + _config = { + .mode = i2s_mode_t(I2S_MODE_MASTER | I2S_MODE_RX), + .sample_rate = _sampleRate, + .bits_per_sample = I2S_SAMPLE_RESOLUTION, + .channel_format = I2S_MIC_CHANNEL, +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(4, 2, 0) + .communication_format = i2s_comm_format_t(I2S_COMM_FORMAT_STAND_I2S), + //.intr_alloc_flags = ESP_INTR_FLAG_LEVEL1, + .intr_alloc_flags = ESP_INTR_FLAG_LEVEL2, + .dma_buf_count = 8, + .dma_buf_len = _blockSize, + .use_apll = 0, + .bits_per_chan = I2S_data_size, +#else + .communication_format = i2s_comm_format_t(I2S_COMM_FORMAT_I2S | I2S_COMM_FORMAT_I2S_MSB), + .intr_alloc_flags = ESP_INTR_FLAG_LEVEL1, + .dma_buf_count = 8, + .dma_buf_len = _blockSize, + .use_apll = false +#endif + }; + } + + virtual void initialize(int8_t i2swsPin = I2S_PIN_NO_CHANGE, int8_t i2ssdPin = I2S_PIN_NO_CHANGE, int8_t i2sckPin = I2S_PIN_NO_CHANGE, int8_t mclkPin = I2S_PIN_NO_CHANGE) { + DEBUGSR_PRINTLN(F("I2SSource:: initialize().")); + if (i2swsPin != I2S_PIN_NO_CHANGE && i2ssdPin != I2S_PIN_NO_CHANGE) { + if (!PinManager::allocatePin(i2swsPin, true, PinOwner::UM_Audioreactive) || + !PinManager::allocatePin(i2ssdPin, false, PinOwner::UM_Audioreactive)) { // #206 + DEBUGSR_PRINTF("\nAR: Failed to allocate I2S pins: ws=%d, sd=%d\n", i2swsPin, i2ssdPin); + return; + } + } + + // i2ssckPin needs special treatment, since it might be unused on PDM mics + if (i2sckPin != I2S_PIN_NO_CHANGE) { + if (!PinManager::allocatePin(i2sckPin, true, PinOwner::UM_Audioreactive)) { + DEBUGSR_PRINTF("\nAR: Failed to allocate I2S pins: sck=%d\n", i2sckPin); + return; + } + } else { + #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(4, 2, 0) + #if !defined(SOC_I2S_SUPPORTS_PDM_RX) + #warning this MCU does not support PDM microphones + #endif + #endif + #if !defined(CONFIG_IDF_TARGET_ESP32S2) && !defined(CONFIG_IDF_TARGET_ESP32C3) + // This is an I2S PDM microphone, these microphones only use a clock and + // data line, to make it simpler to debug, use the WS pin as CLK and SD pin as DATA + // example from espressif: https://github.com/espressif/esp-idf/blob/release/v4.4/examples/peripherals/i2s/i2s_audio_recorder_sdcard/main/i2s_recorder_main.c + + // note to self: PDM has known bugs on S3, and does not work on C3 + // * S3: PDM sample rate only at 50% of expected rate: https://github.com/espressif/esp-idf/issues/9893 + // * S3: I2S PDM has very low amplitude: https://github.com/espressif/esp-idf/issues/8660 + // * C3: does not support PDM to PCM input. SoC would allow PDM RX, but there is no hardware to directly convert to PCM so it will not work. https://github.com/espressif/esp-idf/issues/8796 + + _config.mode = i2s_mode_t(I2S_MODE_MASTER | I2S_MODE_RX | I2S_MODE_PDM); // Change mode to pdm if clock pin not provided. PDM is not supported on ESP32-S2. PDM RX not supported on ESP32-C3 + _config.channel_format =I2S_PDM_MIC_CHANNEL; // seems that PDM mono mode always uses left channel. + _config.use_apll = false; // don't use aPLL clock source (fix for #5391) + #endif + } + +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(4, 2, 0) + if (mclkPin != I2S_PIN_NO_CHANGE) { + #if !defined(WLED_USE_ETHERNET) // fix for #5391 aPLL resource conflict - aPLL is needed for ethernet boards with internal RMII clock + _config.use_apll = true; // experimental - use aPLL clock source to improve sampling quality, and to avoid glitches. + // //_config.fixed_mclk = 512 * _sampleRate; + // //_config.fixed_mclk = 256 * _sampleRate; + #endif + } + + #if !defined(SOC_I2S_SUPPORTS_APLL) + #warning this MCU does not have an APLL high accuracy clock for audio + // S3: not supported; S2: supported; C3: not supported + _config.use_apll = false; // APLL not supported on this MCU + #endif + #if defined(ARDUINO_ARCH_ESP32) && !defined(CONFIG_IDF_TARGET_ESP32S3) && !defined(CONFIG_IDF_TARGET_ESP32S2) && !defined(CONFIG_IDF_TARGET_ESP32C3) + if (ESP.getChipRevision() == 0) _config.use_apll = false; // APLL is broken on ESP32 revision 0 + #endif +#endif + + // Reserve the master clock pin if provided + _mclkPin = mclkPin; + if (mclkPin != I2S_PIN_NO_CHANGE) { + if(!PinManager::allocatePin(mclkPin, true, PinOwner::UM_Audioreactive)) { + DEBUGSR_PRINTF("\nAR: Failed to allocate I2S pin: MCLK=%d\n", mclkPin); + return; + } else + _routeMclk(mclkPin); + } + + _pinConfig = { +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(4, 4, 0) + .mck_io_num = mclkPin, // "classic" ESP32 supports setting MCK on GPIO0/GPIO1/GPIO3 only. i2s_set_pin() will fail if wrong mck_io_num is provided. +#endif + .bck_io_num = i2sckPin, + .ws_io_num = i2swsPin, + .data_out_num = I2S_PIN_NO_CHANGE, + .data_in_num = i2ssdPin + }; + + //DEBUGSR_PRINTF("[AR] I2S: SD=%d, WS=%d, SCK=%d, MCLK=%d\n", i2ssdPin, i2swsPin, i2sckPin, mclkPin); + + esp_err_t err = i2s_driver_install(I2S_NUM_0, &_config, 0, nullptr); + if (err != ESP_OK) { + DEBUGSR_PRINTF("AR: Failed to install i2s driver: %d\n", err); + return; + } + + DEBUGSR_PRINTF("AR: I2S#0 driver %s aPLL; fixed_mclk=%d.\n", _config.use_apll? "uses":"without", _config.fixed_mclk); + DEBUGSR_PRINTF("AR: %d bits, Sample scaling factor = %6.4f\n", _config.bits_per_sample, _sampleScale); + if (_config.mode & I2S_MODE_PDM) { + DEBUGSR_PRINTLN(F("AR: I2S#0 driver installed in PDM MASTER mode.")); + } else { + DEBUGSR_PRINTLN(F("AR: I2S#0 driver installed in MASTER mode.")); + } + + err = i2s_set_pin(I2S_NUM_0, &_pinConfig); + if (err != ESP_OK) { + DEBUGSR_PRINTF("AR: Failed to set i2s pin config: %d\n", err); + i2s_driver_uninstall(I2S_NUM_0); // uninstall already-installed driver + return; + } + +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(4, 2, 0) + err = i2s_set_clk(I2S_NUM_0, _sampleRate, I2S_SAMPLE_RESOLUTION, I2S_CHANNEL_MONO); // set bit clocks. Also takes care of MCLK routing if needed. + if (err != ESP_OK) { + DEBUGSR_PRINTF("AR: Failed to configure i2s clocks: %d\n", err); + i2s_driver_uninstall(I2S_NUM_0); // uninstall already-installed driver + return; + } +#endif + _initialized = true; + } + + virtual void deinitialize() { + _initialized = false; + esp_err_t err = i2s_driver_uninstall(I2S_NUM_0); + if (err != ESP_OK) { + DEBUGSR_PRINTF("Failed to uninstall i2s driver: %d\n", err); + return; + } + if (_pinConfig.ws_io_num != I2S_PIN_NO_CHANGE) PinManager::deallocatePin(_pinConfig.ws_io_num, PinOwner::UM_Audioreactive); + if (_pinConfig.data_in_num != I2S_PIN_NO_CHANGE) PinManager::deallocatePin(_pinConfig.data_in_num, PinOwner::UM_Audioreactive); + if (_pinConfig.bck_io_num != I2S_PIN_NO_CHANGE) PinManager::deallocatePin(_pinConfig.bck_io_num, PinOwner::UM_Audioreactive); + // Release the master clock pin + if (_mclkPin != I2S_PIN_NO_CHANGE) PinManager::deallocatePin(_mclkPin, PinOwner::UM_Audioreactive); + } + + virtual void getSamples(FFTsampleType *buffer, uint16_t num_samples) { + if (_initialized) { + esp_err_t err; + size_t bytes_read = 0; /* Counter variable to check if we actually got enough data */ + I2S_datatype newSamples[num_samples]; /* Intermediary sample storage */ + + err = i2s_read(I2S_NUM_0, (void *)newSamples, sizeof(newSamples), &bytes_read, portMAX_DELAY); + if (err != ESP_OK) { + DEBUGSR_PRINTF("Failed to get samples: %d\n", err); + return; + } + + // For correct operation, we need to read exactly sizeof(samples) bytes from i2s + if (bytes_read != sizeof(newSamples)) { + DEBUGSR_PRINTF("Failed to get enough samples: wanted: %d read: %d\n", sizeof(newSamples), bytes_read); + return; + } + + // Store samples in sample buffer +#if defined(UM_AUDIOREACTIVE_USE_INTEGER_FFT) + //constexpr int32_t FIXEDSHIFT = 8; // shift by 8 bits for fixed point math (no loss at 24bit input sample resolution) + //int32_t intSampleScale = _sampleScale * (1< 16bit; keeping lower 16bits as decimal places + #else + float currSample = (float) newSamples[i]; // 16bit input -> use as-is + #endif + buffer[i] = currSample; + buffer[i] *= _sampleScale; // scale samples +#else + #ifdef I2S_SAMPLE_DOWNSCALE_TO_16BIT + // note on sample scaling: scaling is only used for inputs with master clock and those are better suited for ESP32 or S3 + // execution speed is critical on single core MCUs + //int32_t currSample = newSamples[i] >> FIXEDSHIFT; // shift to avoid overlow in multiplication + //currSample = (currSample * intSampleScale) >> 16; // scale samples, shift down to 16bit + int16_t currSample = newSamples[i] >> 16; // no sample scaling, just shift down to 16bit (not scaling saves ~0.4ms on C3) + #else + //int32_t currSample = (newSamples[i] * intSampleScale) >> FIXEDSHIFT; // scale samples, shift back down to 16bit + int16_t currSample = newSamples[i]; // 16bit input -> use as-is + #endif + buffer[i] = (int16_t)currSample; +#endif + } + } + } + + protected: + void _routeMclk(int8_t mclkPin) { +#if !defined(CONFIG_IDF_TARGET_ESP32S2) && !defined(CONFIG_IDF_TARGET_ESP32C3) && !defined(CONFIG_IDF_TARGET_ESP32S3) + // MCLK routing by writing registers is not needed any more with IDF > 4.4.0 + #if ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(4, 4, 0) + // this way of MCLK routing only works on "classic" ESP32 + /* Enable the mclk routing depending on the selected mclk pin (ESP32: only 0,1,3) + Only I2S_NUM_0 is supported + */ + if (mclkPin == GPIO_NUM_0) { + PIN_FUNC_SELECT(PERIPHS_IO_MUX_GPIO0_U, FUNC_GPIO0_CLK_OUT1); + WRITE_PERI_REG(PIN_CTRL,0xFFF0); + } else if (mclkPin == GPIO_NUM_1) { + PIN_FUNC_SELECT(PERIPHS_IO_MUX_U0TXD_U, FUNC_U0TXD_CLK_OUT3); + WRITE_PERI_REG(PIN_CTRL, 0xF0F0); + } else { + PIN_FUNC_SELECT(PERIPHS_IO_MUX_U0RXD_U, FUNC_U0RXD_CLK_OUT2); + WRITE_PERI_REG(PIN_CTRL, 0xFF00); + } + #endif +#endif + } + + i2s_config_t _config; + i2s_pin_config_t _pinConfig; + int8_t _mclkPin; +}; + +/* ES7243 Microphone + This is an I2S microphone that requires initialization over + I2C before I2S data can be received +*/ +class ES7243 : public I2SSource { + private: + + void _es7243I2cWrite(uint8_t reg, uint8_t val) { + #ifndef ES7243_ADDR + #define ES7243_ADDR 0x13 // default address + #endif + Wire.beginTransmission(ES7243_ADDR); + Wire.write((uint8_t)reg); + Wire.write((uint8_t)val); + uint8_t i2cErr = Wire.endTransmission(); // i2cErr == 0 means OK + if (i2cErr != 0) { + DEBUGSR_PRINTF("AR: ES7243 I2C write failed with error=%d (addr=0x%X, reg 0x%X, val 0x%X).\n", i2cErr, ES7243_ADDR, reg, val); + } + } + + void _es7243InitAdc() { + _es7243I2cWrite(0x00, 0x01); + _es7243I2cWrite(0x06, 0x00); + _es7243I2cWrite(0x05, 0x1B); + _es7243I2cWrite(0x01, 0x00); // 0x00 for 24 bit to match INMP441 - not sure if this needs adjustment to get 16bit samples from I2S + _es7243I2cWrite(0x08, 0x43); + _es7243I2cWrite(0x05, 0x13); + } + +public: + ES7243(SRate_t sampleRate, int blockSize, float sampleScale = 1.0f) : + I2SSource(sampleRate, blockSize, sampleScale) { + _config.channel_format = I2S_CHANNEL_FMT_ONLY_RIGHT; + }; + + void initialize(int8_t i2swsPin, int8_t i2ssdPin, int8_t i2sckPin, int8_t mclkPin) { + DEBUGSR_PRINTLN(F("ES7243:: initialize();")); + if ((i2sckPin < 0) || (mclkPin < 0)) { + DEBUGSR_PRINTF("\nAR: invalid I2S pin: SCK=%d, MCLK=%d\n", i2sckPin, mclkPin); + return; + } + + // First route mclk, then configure ADC over I2C, then configure I2S + _es7243InitAdc(); + I2SSource::initialize(i2swsPin, i2ssdPin, i2sckPin, mclkPin); + } + + void deinitialize() { + I2SSource::deinitialize(); + } +}; + +/* ES8388 Sound Module + This is an I2S sound processing unit that requires initialization over + I2C before I2S data can be received. +*/ +class ES8388Source : public I2SSource { + private: + + void _es8388I2cWrite(uint8_t reg, uint8_t val) { +#ifndef ES8388_ADDR + Wire.beginTransmission(0x10); + #define ES8388_ADDR 0x10 // default address +#else + Wire.beginTransmission(ES8388_ADDR); +#endif + Wire.write((uint8_t)reg); + Wire.write((uint8_t)val); + uint8_t i2cErr = Wire.endTransmission(); // i2cErr == 0 means OK + if (i2cErr != 0) { + DEBUGSR_PRINTF("AR: ES8388 I2C write failed with error=%d (addr=0x%X, reg 0x%X, val 0x%X).\n", i2cErr, ES8388_ADDR, reg, val); + } + } + + void _es8388InitAdc() { + // https://dl.radxa.com/rock2/docs/hw/ds/ES8388%20user%20Guide.pdf Section 10.1 + // http://www.everest-semi.com/pdf/ES8388%20DS.pdf Better spec sheet, more clear. + // https://docs.google.com/spreadsheets/d/1CN3MvhkcPVESuxKyx1xRYqfUit5hOdsG45St9BCUm-g/edit#gid=0 generally + // Sets ADC to around what AudioReactive expects, and loops line-in to line-out/headphone for monitoring. + // Registries are decimal, settings are binary as that's how everything is listed in the docs + // ...which makes it easier to reference the docs. + // + _es8388I2cWrite( 8,0b00000000); // I2S to slave + _es8388I2cWrite( 2,0b11110011); // Power down DEM and STM + _es8388I2cWrite(43,0b10000000); // Set same LRCK + _es8388I2cWrite( 0,0b00000101); // Set chip to Play & Record Mode + _es8388I2cWrite(13,0b00000010); // Set MCLK/LRCK ratio to 256 + _es8388I2cWrite( 1,0b01000000); // Power up analog and lbias + _es8388I2cWrite( 3,0b00000000); // Power up ADC, Analog Input, and Mic Bias + _es8388I2cWrite( 4,0b11111100); // Power down DAC, Turn on LOUT1 and ROUT1 and LOUT2 and ROUT2 power + _es8388I2cWrite( 2,0b01000000); // Power up DEM and STM and undocumented bit for "turn on line-out amp" + + // #define use_es8388_mic + + #ifdef use_es8388_mic + // The mics *and* line-in are BOTH connected to LIN2/RIN2 on the AudioKit + // so there's no way to completely eliminate the mics. It's also hella noisy. + // Line-in works OK on the AudioKit, generally speaking, as the mics really need + // amplification to be noticeable in a quiet room. If you're in a very loud room, + // the mics on the AudioKit WILL pick up sound even in line-in mode. + // TL;DR: Don't use the AudioKit for anything, use the LyraT. + // + // The LyraT does a reasonable job with mic input as configured below. + + // Pick one of these. If you have to use the mics, use a LyraT over an AudioKit if you can: + _es8388I2cWrite(10,0b00000000); // Use Lin1/Rin1 for ADC input (mic on LyraT) + //_es8388I2cWrite(10,0b01010000); // Use Lin2/Rin2 for ADC input (mic *and* line-in on AudioKit) + + _es8388I2cWrite( 9,0b10001000); // Select Analog Input PGA Gain for ADC to +24dB (L+R) + _es8388I2cWrite(16,0b00000000); // Set ADC digital volume attenuation to 0dB (left) + _es8388I2cWrite(17,0b00000000); // Set ADC digital volume attenuation to 0dB (right) + _es8388I2cWrite(38,0b00011011); // Mixer - route LIN1/RIN1 to output after mic gain + + _es8388I2cWrite(39,0b01000000); // Mixer - route LIN to mixL, +6dB gain + _es8388I2cWrite(42,0b01000000); // Mixer - route RIN to mixR, +6dB gain + _es8388I2cWrite(46,0b00100001); // LOUT1VOL - 0b00100001 = +4.5dB + _es8388I2cWrite(47,0b00100001); // ROUT1VOL - 0b00100001 = +4.5dB + _es8388I2cWrite(48,0b00100001); // LOUT2VOL - 0b00100001 = +4.5dB + _es8388I2cWrite(49,0b00100001); // ROUT2VOL - 0b00100001 = +4.5dB + + // Music ALC - the mics like Auto Level Control + // You can also use this for line-in, but it's not really needed. + // + _es8388I2cWrite(18,0b11111000); // ALC: stereo, max gain +35.5dB, min gain -12dB + _es8388I2cWrite(19,0b00110000); // ALC: target -1.5dB, 0ms hold time + _es8388I2cWrite(20,0b10100110); // ALC: gain ramp up = 420ms/93ms, gain ramp down = check manual for calc + _es8388I2cWrite(21,0b00000110); // ALC: use "ALC" mode, no zero-cross, window 96 samples + _es8388I2cWrite(22,0b01011001); // ALC: noise gate threshold, PGA gain constant, noise gate enabled + #else + _es8388I2cWrite(10,0b01010000); // Use Lin2/Rin2 for ADC input ("line-in") + _es8388I2cWrite( 9,0b00000000); // Select Analog Input PGA Gain for ADC to 0dB (L+R) + _es8388I2cWrite(16,0b01000000); // Set ADC digital volume attenuation to -32dB (left) + _es8388I2cWrite(17,0b01000000); // Set ADC digital volume attenuation to -32dB (right) + _es8388I2cWrite(38,0b00001001); // Mixer - route LIN2/RIN2 to output + + _es8388I2cWrite(39,0b01010000); // Mixer - route LIN to mixL, 0dB gain + _es8388I2cWrite(42,0b01010000); // Mixer - route RIN to mixR, 0dB gain + _es8388I2cWrite(46,0b00011011); // LOUT1VOL - 0b00011110 = +0dB, 0b00011011 = LyraT balance fix + _es8388I2cWrite(47,0b00011110); // ROUT1VOL - 0b00011110 = +0dB + _es8388I2cWrite(48,0b00011110); // LOUT2VOL - 0b00011110 = +0dB + _es8388I2cWrite(49,0b00011110); // ROUT2VOL - 0b00011110 = +0dB + #endif + + } + + public: + ES8388Source(SRate_t sampleRate, int blockSize, float sampleScale = 1.0f, bool i2sMaster=true) : + I2SSource(sampleRate, blockSize, sampleScale) { + _config.channel_format = I2S_CHANNEL_FMT_ONLY_LEFT; + }; + + void initialize(int8_t i2swsPin, int8_t i2ssdPin, int8_t i2sckPin, int8_t mclkPin) { + DEBUGSR_PRINTLN(F("ES8388Source:: initialize();")); + if ((i2sckPin < 0) || (mclkPin < 0)) { + DEBUGSR_PRINTF("\nAR: invalid I2S pin: SCK=%d, MCLK=%d\n", i2sckPin, mclkPin); + return; + } + + // First route mclk, then configure ADC over I2C, then configure I2S + _es8388InitAdc(); + I2SSource::initialize(i2swsPin, i2ssdPin, i2sckPin, mclkPin); + } + + void deinitialize() { + I2SSource::deinitialize(); + } + +}; + +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(4, 2, 0) +#if !defined(SOC_I2S_SUPPORTS_ADC) && !defined(SOC_I2S_SUPPORTS_ADC_DAC) + #warning this MCU does not support analog sound input +#endif +#endif + +#if !defined(CONFIG_IDF_TARGET_ESP32S2) && !defined(CONFIG_IDF_TARGET_ESP32C3) && !defined(CONFIG_IDF_TARGET_ESP32S3) +// ADC over I2S is only availeable in "classic" ESP32 + +/* ADC over I2S Microphone + This microphone is an ADC pin sampled via the I2S interval + This allows to use the I2S API to obtain ADC samples with high sample rates + without the need of manual timing of the samples +*/ +class I2SAdcSource : public I2SSource { + public: + I2SAdcSource(SRate_t sampleRate, int blockSize, float sampleScale = 1.0f) : + I2SSource(sampleRate, blockSize, sampleScale) { + _config = { + .mode = i2s_mode_t(I2S_MODE_MASTER | I2S_MODE_RX | I2S_MODE_ADC_BUILT_IN), + .sample_rate = _sampleRate, + .bits_per_sample = I2S_SAMPLE_RESOLUTION, + .channel_format = I2S_CHANNEL_FMT_ONLY_LEFT, +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(4, 2, 0) + .communication_format = i2s_comm_format_t(I2S_COMM_FORMAT_STAND_I2S), +#else + .communication_format = i2s_comm_format_t(I2S_COMM_FORMAT_I2S | I2S_COMM_FORMAT_I2S_MSB), +#endif + .intr_alloc_flags = ESP_INTR_FLAG_LEVEL1, + .dma_buf_count = 8, + .dma_buf_len = _blockSize, + .use_apll = false, + .tx_desc_auto_clear = false, + .fixed_mclk = 0 + }; + } + + /* identify Audiosource type - I2S-ADC*/ + AudioSourceType getType(void) {return(Type_I2SAdc);} + + void initialize(int8_t audioPin, int8_t = I2S_PIN_NO_CHANGE, int8_t = I2S_PIN_NO_CHANGE, int8_t = I2S_PIN_NO_CHANGE) { + DEBUGSR_PRINTLN(F("I2SAdcSource:: initialize().")); + _myADCchannel = 0x0F; + if(!PinManager::allocatePin(audioPin, false, PinOwner::UM_Audioreactive)) { + DEBUGSR_PRINTF("failed to allocate GPIO for audio analog input: %d\n", audioPin); + return; + } + _audioPin = audioPin; + + // Determine Analog channel. Only Channels on ADC1 are supported + int8_t channel = digitalPinToAnalogChannel(_audioPin); + if (channel > 9) { + DEBUGSR_PRINTF("Incompatible GPIO used for analog audio input: %d\n", _audioPin); + return; + } else { + adc_gpio_init(ADC_UNIT_1, adc_channel_t(channel)); + _myADCchannel = channel; + } + + // Install Driver + esp_err_t err = i2s_driver_install(I2S_NUM_0, &_config, 0, nullptr); + if (err != ESP_OK) { + DEBUGSR_PRINTF("Failed to install i2s driver: %d\n", err); + return; + } + + adc1_config_width(ADC_WIDTH_BIT_12); // ensure that ADC runs with 12bit resolution + + // Enable I2S mode of ADC + err = i2s_set_adc_mode(ADC_UNIT_1, adc1_channel_t(channel)); + if (err != ESP_OK) { + DEBUGSR_PRINTF("Failed to set i2s adc mode: %d\n", err); + return; + } + + // see example in https://github.com/espressif/arduino-esp32/blob/master/libraries/ESP32/examples/I2S/HiFreq_ADC/HiFreq_ADC.ino + adc1_config_channel_atten(adc1_channel_t(channel), ADC_ATTEN_DB_12); // configure ADC input amplification + + #if defined(I2S_GRAB_ADC1_COMPLETELY) + // according to docs from espressif, the ADC needs to be started explicitly + // fingers crossed + err = i2s_adc_enable(I2S_NUM_0); + if (err != ESP_OK) { + DEBUGSR_PRINTF("Failed to enable i2s adc: %d\n", err); + //return; + } + #else + // bugfix: do not disable ADC initially - its already disabled after driver install. + //err = i2s_adc_disable(I2S_NUM_0); + // //err = i2s_stop(I2S_NUM_0); + //if (err != ESP_OK) { + // DEBUGSR_PRINTF("Failed to initially disable i2s adc: %d\n", err); + //} + #endif + + _initialized = true; + } + + + I2S_datatype postProcessSample(I2S_datatype sample_in) { + static I2S_datatype lastADCsample = 0; // last good sample + static unsigned int broken_samples_counter = 0; // number of consecutive broken (and fixed) ADC samples + I2S_datatype sample_out = 0; + + // bring sample down down to 16bit unsigned + I2S_unsigned_datatype rawData = * reinterpret_cast (&sample_in); // C++ acrobatics to get sample as "unsigned" + #ifndef I2S_USE_16BIT_SAMPLES + rawData = (rawData >> 16) & 0xFFFF; // scale input down from 32bit -> 16bit + I2S_datatype lastGoodSample = lastADCsample / 16384 ; // prepare "last good sample" accordingly (26bit-> 12bit with correct sign handling) + #else + rawData = rawData & 0xFFFF; // input is already in 16bit, just mask off possible junk + I2S_datatype lastGoodSample = lastADCsample * 4; // prepare "last good sample" accordingly (10bit-> 12bit) + #endif + + // decode ADC sample data fields + uint16_t the_channel = (rawData >> 12) & 0x000F; // upper 4 bit = ADC channel + uint16_t the_sample = rawData & 0x0FFF; // lower 12bit -> ADC sample (unsigned) + I2S_datatype finalSample = (int(the_sample) - 2048); // convert unsigned sample to signed (centered at 0); + + if ((the_channel != _myADCchannel) && (_myADCchannel != 0x0F)) { // 0x0F means "don't know what my channel is" + // fix bad sample + finalSample = lastGoodSample; // replace with last good ADC sample + broken_samples_counter ++; + if (broken_samples_counter > 256) _myADCchannel = 0x0F; // too many bad samples in a row -> disable sample corrections + //Serial.print("\n!ADC rogue sample 0x"); Serial.print(rawData, HEX); Serial.print("\tchannel:");Serial.println(the_channel); + } else broken_samples_counter = 0; // good sample - reset counter + + // back to original resolution + #ifndef I2S_USE_16BIT_SAMPLES + finalSample = finalSample << 16; // scale up from 16bit -> 32bit; + #endif + + finalSample = finalSample / 4; // mimic old analog driver behaviour (12bit -> 10bit) + sample_out = (3 * finalSample + lastADCsample) / 4; // apply low-pass filter (2-tap FIR) + //sample_out = (finalSample + lastADCsample) / 2; // apply stronger low-pass filter (2-tap FIR) + + lastADCsample = sample_out; // update ADC last sample + return(sample_out); + } + + + void getSamples(FFTsampleType *buffer, uint16_t num_samples) { + /* Enable ADC. This has to be enabled and disabled directly before and + * after sampling, otherwise Wifi dies + */ + if (_initialized) { + #if !defined(I2S_GRAB_ADC1_COMPLETELY) + // old code - works for me without enable/disable, at least on ESP32. + //esp_err_t err = i2s_start(I2S_NUM_0); + esp_err_t err = i2s_adc_enable(I2S_NUM_0); + if (err != ESP_OK) { + DEBUGSR_PRINTF("Failed to enable i2s adc: %d\n", err); + return; + } + #endif + + I2SSource::getSamples(buffer, num_samples); + + #if !defined(I2S_GRAB_ADC1_COMPLETELY) + // old code - works for me without enable/disable, at least on ESP32. + err = i2s_adc_disable(I2S_NUM_0); //i2s_adc_disable() may cause crash with IDF 4.4 (https://github.com/espressif/arduino-esp32/issues/6832) + //err = i2s_stop(I2S_NUM_0); + if (err != ESP_OK) { + DEBUGSR_PRINTF("Failed to disable i2s adc: %d\n", err); + return; + } + #endif + } + } + + void deinitialize() { + PinManager::deallocatePin(_audioPin, PinOwner::UM_Audioreactive); + _initialized = false; + _myADCchannel = 0x0F; + + esp_err_t err; + #if defined(I2S_GRAB_ADC1_COMPLETELY) + // according to docs from espressif, the ADC needs to be stopped explicitly + // fingers crossed + err = i2s_adc_disable(I2S_NUM_0); + if (err != ESP_OK) { + DEBUGSR_PRINTF("Failed to disable i2s adc: %d\n", err); + } + #endif + + i2s_stop(I2S_NUM_0); + err = i2s_driver_uninstall(I2S_NUM_0); + if (err != ESP_OK) { + DEBUGSR_PRINTF("Failed to uninstall i2s driver: %d\n", err); + return; + } + } + + private: + int8_t _audioPin; + int8_t _myADCchannel = 0x0F; // current ADC channel for analog input. 0x0F means "undefined" +}; +#endif + +/* SPH0645 Microphone + This is an I2S microphone with some timing quirks that need + special consideration. +*/ + +// https://github.com/espressif/esp-idf/issues/7192 SPH0645 i2s microphone issue when migrate from legacy esp-idf version (IDFGH-5453) +// a user recommended this: Try to set .communication_format to I2S_COMM_FORMAT_STAND_I2S and call i2s_set_clk() after i2s_set_pin(). +class SPH0654 : public I2SSource { + public: + SPH0654(SRate_t sampleRate, int blockSize, float sampleScale = 1.0f) : + I2SSource(sampleRate, blockSize, sampleScale) + {} + + void initialize(int8_t i2swsPin, int8_t i2ssdPin, int8_t i2sckPin, int8_t = I2S_PIN_NO_CHANGE) { + DEBUGSR_PRINTLN(F("SPH0654:: initialize();")); + I2SSource::initialize(i2swsPin, i2ssdPin, i2sckPin); +#if !defined(CONFIG_IDF_TARGET_ESP32S2) && !defined(CONFIG_IDF_TARGET_ESP32C3) && !defined(CONFIG_IDF_TARGET_ESP32S3) +// these registers are only existing in "classic" ESP32 + REG_SET_BIT(I2S_TIMING_REG(I2S_NUM_0), BIT(9)); + REG_SET_BIT(I2S_CONF_REG(I2S_NUM_0), I2S_RX_MSB_SHIFT); +#else + #warning FIX ME! Please. +#endif + } +}; +#endif diff --git a/usermods/msgeq7/library.json b/usermods/msgeq7/library.json new file mode 100644 index 0000000000..144992d978 --- /dev/null +++ b/usermods/msgeq7/library.json @@ -0,0 +1,4 @@ +{ + "name": "msgeq7", + "build": { "libArchive": false } +} diff --git a/usermods/msgeq7/msgeq7.cpp b/usermods/msgeq7/msgeq7.cpp new file mode 100644 index 0000000000..d82e6bff00 --- /dev/null +++ b/usermods/msgeq7/msgeq7.cpp @@ -0,0 +1,895 @@ +/* + * msgeq7 — Software MSGEQ7 audio-reactive usermod for WLED + * + * Produces the same um_data_t 8-slot structure as the audioreactive usermod, + * making it a drop-in replacement. Uses either: + * (A) Software backend: I2S/ADC microphone + 7 biquad bandpass filters at the + * classic MSGEQ7 center frequencies (63, 160, 400, 1k, 2.5k, 6.25k, 16k Hz). + * (B) Hardware backend: physical MSGEQ7 chip read via strobe/reset/OUT pins. + * + * Both backends produce 7 band amplitudes which are then interpolated to fill + * the 16-channel fftResult[] array expected by WLED effects. + * + * Registers as USERMOD_ID_AUDIOREACTIVE (32) so all existing audio-reactive + * effects work without modification. Cannot be loaded simultaneously with the + * audioreactive usermod. + * + * Activate via platformio.ini override: + * custom_usermods = msgeq7 + * + * AI: below section was generated by an AI + */ + +#include "wled.h" + +#ifndef ARDUINO_ARCH_ESP32 +#error "msgeq7 usermod requires ESP32" +#endif + +// FFTsampleType must be defined before including audio_source.h, which uses it +// in its getSamples() virtual method signature. +using FFTsampleType = float; + +// DEBUGSR_* macros are used throughout audio_source.h. +// Define them before the include; mirror audioreactive's SR_DEBUG pattern. +#ifdef SR_DEBUG + #define DEBUGSR_PRINT(x) DEBUGOUT.print(x) + #define DEBUGSR_PRINTLN(x) DEBUGOUT.println(x) + #define DEBUGSR_PRINTF(...) DEBUGOUT.printf(__VA_ARGS__) +#else + #define DEBUGSR_PRINT(x) + #define DEBUGSR_PRINTLN(x) + #define DEBUGSR_PRINTF(...) +#endif + +#include "audio_source.h" + +#include // esp-dsp: coefficient generation +#include // esp-dsp: filter processing + +// ─── Constants ─────────────────────────────────────────────────────────────── + +// Software backend sample rate. Must be ≥ 32000 Hz to represent the 16 kHz band. +// 44100 Hz is the standard CD audio rate and gives a comfortable margin above 16 kHz. +static constexpr SRate_t MSGEQ7_SAMPLE_RATE = 44100; + +// I2S DMA block size (samples per read). Chosen to match audioreactive. +static constexpr int MSGEQ7_BLOCK_SIZE = 128; + +// Number of MSGEQ7 frequency bands. +static constexpr int NUM_BANDS = 7; + +// Number of GEQ output channels expected by WLED effects. +static constexpr int NUM_GEQ_CHANNELS = 16; + +// Center frequencies of the 7 MSGEQ7 bandpass filters (Hz). +// Source: MSGEQ7 datasheet (Mixed Signal Integration MSI, Inc.). +static constexpr float MSGEQ7_FREQS_HZ[NUM_BANDS] = { + 63.0f, 160.0f, 400.0f, 1000.0f, 2500.0f, 6250.0f, 16000.0f +}; + +// Default filter Q (quality factor). Q ≈ 1.4 gives ~one-octave 3dB bandwidth per +// band, similar to the real MSGEQ7 chip's overlapping filter character. +static constexpr float MSGEQ7_DEFAULT_Q = 1.4f; + +// Envelope detector time constants (seconds). +// Approximate real MSGEQ7 peak-hold behaviour: fast attack, slow decay. +static constexpr float MSGEQ7_ATTACK_SEC = 0.015f; // 15 ms attack +static constexpr float MSGEQ7_DECAY_SEC = 0.080f; // 80 ms decay + +// Log compression: output = 255 * log10(1 + MSGEQ7_LOG_SCALE * normalised_input) +// MSGEQ7_LOG_SCALE = 9 maps linear 0..1 → compressed 0..1 with more low-signal +// resolution, matching the real chip's logarithmic output characteristic. +static constexpr float MSGEQ7_LOG_SCALE = 9.0f; + +// FreeRTOS task settings for the software processing task. +static constexpr uint8_t MSGEQ7_TASK_PRIORITY = 1; +static constexpr uint32_t MSGEQ7_TASK_STACK = 3072; // bytes + +// Minimum ms between hardware MSGEQ7 chip reads. The chip needs ~36 µs per band +// strobe, so 20 ms gives ~50 Hz update rate which is plenty. +static constexpr uint32_t MSGEQ7_HW_READ_INTERVAL_MS = 20; + +// Beat/peak detection: set samplePeak when the rate of rise of the sum of the +// two lowest bands exceeds this fraction of the current peak. +static constexpr float MSGEQ7_PEAK_RISE_THRESHOLD = 0.15f; + +// ─── Global shared state (written by task/ISR, read by main loop) ──────────── +// Protected conceptually by the single-writer/single-reader property: +// the processing task is the sole writer; loop() is the sole reader. +// A memory barrier via volatile is sufficient here — no mutex needed. + +static volatile float s_bandEnvelope[NUM_BANDS] = {}; // 0.0–255.0 per band +static volatile float s_volumeSmth = 0.0f; +static volatile int16_t s_volumeRaw = 0; +static volatile float s_FFT_MajorPeak = 1.0f; +static volatile float s_my_magnitude = 0.001f; +static volatile uint8_t s_samplePeak = 0; + +// Written by effects, read by effects (via um_data pointers). These are +// intentionally writable from effect code (see audioreactive comment in setup()). +static uint8_t s_maxVol = 31; +static uint8_t s_binNum = 8; + +// ─── Interpolation table: 7 MSGEQ7 bands → 16 GEQ channels ───────────────── +// +// Each of the 16 GEQ output channels is assigned a representative frequency +// derived from audioreactive's FFT bin groupings at 22050 Hz. We then locate +// the two bracketing MSGEQ7 bands and store a linear interpolation weight in +// log-frequency space. +// +// This table is computed once in setup() and used every loop iteration. + +struct GEQInterp { + uint8_t lo; // lower MSGEQ7 band index + uint8_t hi; // upper MSGEQ7 band index (lo+1, clamped to NUM_BANDS-1) + float t; // interpolation weight towards hi: output = lo*(1-t) + hi*t +}; + +static GEQInterp s_geqInterp[NUM_GEQ_CHANNELS]; + +// Representative center frequencies for the 16 GEQ channels (Hz). +// Derived from audioreactive's fftAddAvg() bin ranges at 22050 Hz / 512 bins +// (43.07 Hz per bin). Center = (from + to) / 2 * 43.07. +// Extended to the 44100 Hz range for channels 14-15. +static const float GEQ_TARGET_FREQS_HZ[NUM_GEQ_CHANNELS] = { + 64.6f, 107.7f, 172.3f, 258.4f, + 344.5f, 472.8f, 667.9f, 926.0f, + 1291.0f, 1829.7f, 2561.6f, 3638.3f, + 5229.9f, 7468.5f, 9117.0f, 12903.0f +}; + +// Precompute s_geqInterp[] from GEQ_TARGET_FREQS_HZ and MSGEQ7_FREQS_HZ. +// Must be called once before the first use of the interpolation table. +static void buildInterpolationTable(void) { + // Use log10 scale so that equal perceptual frequency distances map linearly. + float logBands[NUM_BANDS]; + for (int b = 0; b < NUM_BANDS; b++) { + logBands[b] = log10f(MSGEQ7_FREQS_HZ[b]); + } + + for (int ch = 0; ch < NUM_GEQ_CHANNELS; ch++) { + float logTarget = log10f(GEQ_TARGET_FREQS_HZ[ch]); + + // Find the two MSGEQ7 bands that bracket this frequency in log space. + int lo = 0; + for (int b = 0; b < NUM_BANDS - 1; b++) { + if (logBands[b] <= logTarget) lo = b; + } + int hi = lo + 1; + if (hi >= NUM_BANDS) hi = NUM_BANDS - 1; + + float t = 0.0f; + if (hi != lo) { + t = (logTarget - logBands[lo]) / (logBands[hi] - logBands[lo]); + // Clamp to [0,1] so out-of-range frequencies saturate at the nearest band. + if (t < 0.0f) t = 0.0f; + if (t > 1.0f) t = 1.0f; + } + + s_geqInterp[ch].lo = (uint8_t)lo; + s_geqInterp[ch].hi = (uint8_t)hi; + s_geqInterp[ch].t = t; + } +} + +// ─── Software backend: biquad filter chain ─────────────────────────────────── + +// esp-dsp biquad requires 16-byte-aligned coefficient and delay-line arrays +// when using the ae32/aes3 SIMD variants. +// Layout per filter: float coeffs[5]={b0,b1,b2,a1,a2}, float w[2]={w0,w1}. +// We store them together so each filter state is contiguous. + +struct alignas(16) BandFilter { + float coeffs[5]; // biquad coefficients produced by dsps_biquad_gen_bpf_f32() + float w[2]; // IIR delay line (initialised to zero; written by filter) +}; + +static BandFilter s_filters[NUM_BANDS]; + +// Compute biquad coefficients for all 7 bands at the given Q and sample rate. +// Called from setup() and whenever Q changes. +static void initBiquadCoeffs(float sampleRate, float Q) { + for (int b = 0; b < NUM_BANDS; b++) { + // Normalised frequency for esp-dsp: f = fc / fs, expected range [0 .. 0.5]. + // The 16 kHz band at 44100 Hz gives f = 0.363 — well within range. + float f = MSGEQ7_FREQS_HZ[b] / sampleRate; + if (f >= 0.5f) f = 0.499f; // hard clamp below Nyquist + dsps_biquad_gen_bpf_f32(s_filters[b].coeffs, f, Q); + s_filters[b].w[0] = 0.0f; + s_filters[b].w[1] = 0.0f; + } +} + +// Compute one-pole attack/decay IIR coefficients from time constants. +// coeff = 1 - exp(-1 / (timeConst_s * sampleRate)) +// For large timeConst the result is near 0 (slow); for small, near 1 (fast). +static float timeConstToCoeff(float timeConst_s, float sampleRate) { + return 1.0f - expf(-1.0f / (timeConst_s * sampleRate)); +} + +// ─── Software processing task ──────────────────────────────────────────────── +// +// Runs on Core 0 with MSGEQ7_TASK_PRIORITY. +// Reads I2S samples in blocks, runs each block through all 7 biquad filters, +// updates envelope detectors, and writes the results to the shared volatile +// state variables. + +struct SWTaskParams { + AudioSource *source; + float Q; + float gainLevel; // linear multiplier applied to raw samples + float squelch; // below this raw magnitude, force envelope to zero + float attackCoeff; // computed from MSGEQ7_ATTACK_SEC + float decayCoeff; // computed from MSGEQ7_DECAY_SEC + volatile bool stop; // set true to request task exit +}; + +static TaskHandle_t s_swTaskHandle = nullptr; + +// AI: below section was generated by an AI +static void IRAM_ATTR softwareProcessingTask(void *pvParams) { + SWTaskParams *p = static_cast(pvParams); + + // Allocate working buffers on the task stack is too risky (3 KB stack). + // Use heap instead. Both buffers hold one I2S block. + float *inputBuf = static_cast(malloc(MSGEQ7_BLOCK_SIZE * sizeof(float))); + float *filteredBuf = static_cast(malloc(MSGEQ7_BLOCK_SIZE * sizeof(float))); + + if (!inputBuf || !filteredBuf) { + free(inputBuf); + free(filteredBuf); + vTaskDelete(nullptr); + return; + } + + float envelope[NUM_BANDS] = {}; // local copy, written back to shared state + float prevLowBandSum = 0.0f; // for peak (beat) detection + float peakHoldTimer = 0.0f; // counts down to auto-clear samplePeak + + const TickType_t xFrequency = pdMS_TO_TICKS(1); // yield every ms (WDT feed) + + while (!p->stop) { + delay(1); // feeds IDLE(0) watchdog — do not remove; see audioreactive comment + + if (!p->source || !p->source->isInitialized()) { + vTaskDelay(xFrequency); + continue; + } + + // --- Read one block of audio samples from I2S DMA --- + p->source->getSamples(inputBuf, MSGEQ7_BLOCK_SIZE); + + // --- Apply input gain and compute raw block peak (for volumeRaw) --- + float blockPeak = 0.0f; + float blockSumSq = 0.0f; + for (int i = 0; i < MSGEQ7_BLOCK_SIZE; i++) { + inputBuf[i] *= p->gainLevel; + float absVal = fabsf(inputBuf[i]); + if (absVal > blockPeak) blockPeak = absVal; + blockSumSq += absVal * absVal; + } + float blockRMS = sqrtf(blockSumSq / MSGEQ7_BLOCK_SIZE); + + // --- Run each biquad bandpass filter over the block, update envelope --- + for (int b = 0; b < NUM_BANDS; b++) { + // Select the best available SIMD variant at compile time. +#if dsps_biquad_f32_aes3_enabled + dsps_biquad_f32_aes3(inputBuf, filteredBuf, MSGEQ7_BLOCK_SIZE, + s_filters[b].coeffs, s_filters[b].w); +#elif dsps_biquad_f32_ae32_enabled + dsps_biquad_f32_ae32(inputBuf, filteredBuf, MSGEQ7_BLOCK_SIZE, + s_filters[b].coeffs, s_filters[b].w); +#else + dsps_biquad_f32_ansi(inputBuf, filteredBuf, MSGEQ7_BLOCK_SIZE, + s_filters[b].coeffs, s_filters[b].w); +#endif + + // Half-wave rectify (abs) then update peak-hold envelope. + float attackC = p->attackCoeff; + float decayC = p->decayCoeff; + float env = envelope[b]; + for (int i = 0; i < MSGEQ7_BLOCK_SIZE; i++) { + float absVal = fabsf(filteredBuf[i]); + if (absVal > env) { + env += (absVal - env) * attackC; + } else { + env += (absVal - env) * decayC; + } + } + // Apply squelch: silence very faint signals to avoid noise floor crawl. + if (env < p->squelch) env = 0.0f; + envelope[b] = env; + } + + // --- Log-compress envelopes to 0..255 scale (matches real MSGEQ7 output) --- + // The compression function: out = 255 * log10(1 + 9*in/peak) / log10(10) + // where `in` is normalised against the current envelope peak. A global + // normalisation factor avoids bands dominating purely due to mic sensitivity. + float envPeak = 0.0f; + for (int b = 0; b < NUM_BANDS; b++) { + if (envelope[b] > envPeak) envPeak = envelope[b]; + } + + float compressedEnv[NUM_BANDS]; + for (int b = 0; b < NUM_BANDS; b++) { + float normalised = (envPeak > 1e-6f) ? (envelope[b] / envPeak) : 0.0f; + // log10(1 + 9*x) / log10(10) maps [0,1] → [0,1] with log curve + compressedEnv[b] = 255.0f * (log10f(1.0f + MSGEQ7_LOG_SCALE * normalised) + / log10f(1.0f + MSGEQ7_LOG_SCALE)); + } + + // --- Beat / samplePeak detection --- + // Detect a sudden rise in sub-bass energy (bands 0 and 1: 63 + 160 Hz). + float lowBandSum = compressedEnv[0] + compressedEnv[1]; + float rise = lowBandSum - prevLowBandSum; + if (rise > MSGEQ7_PEAK_RISE_THRESHOLD * 255.0f && lowBandSum > p->squelch) { + s_samplePeak = 1; + peakHoldTimer = 40.0f; // hold for ~40 loop iterations (≈ 1 LED frame at 25fps) + } + prevLowBandSum = lowBandSum; + if (peakHoldTimer > 0.0f) { + peakHoldTimer -= 1.0f; + if (peakHoldTimer <= 0.0f) s_samplePeak = 0; + } + + // --- FFT_MajorPeak via parabolic interpolation of top-2 bands --- + // Find the band with the highest compressed amplitude. + int peakBand = 0; + for (int b = 1; b < NUM_BANDS; b++) { + if (compressedEnv[b] > compressedEnv[peakBand]) peakBand = b; + } + float majorPeak = MSGEQ7_FREQS_HZ[peakBand]; + // Parabolic interpolation using log-spaced frequencies smooths the + // staircase artifact seen in FREQMAP/WATERFALL effects. + if (peakBand > 0 && peakBand < NUM_BANDS - 1) { + float a = compressedEnv[peakBand - 1]; + float b_val = compressedEnv[peakBand]; + float c = compressedEnv[peakBand + 1]; + float denom = a - 2.0f * b_val + c; + if (fabsf(denom) > 1e-6f) { + // Fractional offset in log-frequency space, range [-0.5, +0.5] + float offset = 0.5f * (a - c) / denom; + float logFreqLo = log10f(MSGEQ7_FREQS_HZ[peakBand > 0 ? peakBand - 1 : 0]); + float logFreqHi = log10f(MSGEQ7_FREQS_HZ[peakBand < NUM_BANDS-1 ? peakBand + 1 : NUM_BANDS-1]); + float logFreqPeak = log10f(MSGEQ7_FREQS_HZ[peakBand]); + float step = (offset >= 0.0f) ? (logFreqHi - logFreqPeak) : (logFreqPeak - logFreqLo); + majorPeak = powf(10.0f, logFreqPeak + offset * step); + } + } + // Clamp to the range expected by effects (1 Hz .. Nyquist). + if (majorPeak < 1.0f) majorPeak = 1.0f; + if (majorPeak > MSGEQ7_SAMPLE_RATE / 2) majorPeak = MSGEQ7_SAMPLE_RATE / 2; + + // --- my_magnitude: scaled peak band amplitude --- + float magnitude = compressedEnv[peakBand] * p->gainLevel; + if (magnitude < 0.001f) magnitude = 0.001f; + + // --- Volume from RMS and block peak --- + // volumeSmth: smoothed RMS-based volume, scaled to 0..255 range. + // Normalise by a typical maximum value then apply log compression. + float normRMS = blockRMS / 32768.0f * p->gainLevel; // float samples are ~[-32768..32768] + float volSmth = 255.0f * (log10f(1.0f + MSGEQ7_LOG_SCALE * constrain(normRMS, 0.0f, 1.0f)) + / log10f(1.0f + MSGEQ7_LOG_SCALE)); + int16_t volRaw = (int16_t)constrain(blockPeak * p->gainLevel / 128.0f, 0.0f, 32767.0f); + + // --- Debug output for sweep_analyze.py (compiled out unless SR_DEBUG) --- +#ifdef SR_DEBUG + DEBUGSR_PRINTF("MSGEQ7 bands: 0=%d 1=%d 2=%d 3=%d 4=%d 5=%d 6=%d\n", + (int)compressedEnv[0], (int)compressedEnv[1], (int)compressedEnv[2], + (int)compressedEnv[3], (int)compressedEnv[4], (int)compressedEnv[5], + (int)compressedEnv[6]); +#endif + + // --- Write to shared volatile state --- + for (int b = 0; b < NUM_BANDS; b++) s_bandEnvelope[b] = compressedEnv[b]; + s_volumeSmth = volSmth; + s_volumeRaw = volRaw; + s_FFT_MajorPeak = majorPeak; + s_my_magnitude = magnitude; + // s_samplePeak is written above + } // while (!p->stop) + + free(inputBuf); + free(filteredBuf); + vTaskDelete(nullptr); +} +// AI: end + +// ─── MSGEQ7 Usermod class ───────────────────────────────────────────────────── + +class MSGEQ7Usermod : public Usermod { + +public: + // ── Lifecycle ────────────────────────────────────────────────────────────── + + void setup() override { + if (_initDone) return; + + // Allocate the um_data structure. Layout mirrors audioreactive exactly so + // existing effects work without any modification. + _umData = new um_data_t; + _umData->u_size = 8; + _umData->u_type = new um_types_t[_umData->u_size]; + _umData->u_data = new void*[_umData->u_size]; + + _umData->u_data[0] = &_volumeSmth; _umData->u_type[0] = UMT_FLOAT; + _umData->u_data[1] = &_volumeRaw; _umData->u_type[1] = UMT_UINT16; + _umData->u_data[2] = _fftResult; _umData->u_type[2] = UMT_BYTE_ARR; + _umData->u_data[3] = &_samplePeak; _umData->u_type[3] = UMT_BYTE; + _umData->u_data[4] = &_FFT_MajorPeak; _umData->u_type[4] = UMT_FLOAT; + _umData->u_data[5] = &_my_magnitude; _umData->u_type[5] = UMT_FLOAT; + _umData->u_data[6] = &s_maxVol; _umData->u_type[6] = UMT_BYTE; + _umData->u_data[7] = &s_binNum; _umData->u_type[7] = UMT_BYTE; + + buildInterpolationTable(); + _initDone = true; + + if (!_enabled) return; + _startProcessing(); + } + + void loop() override { + if (!_enabled) return; + // Guard: be nice to LED refresh, same pattern as audioreactive + if (strip.isUpdating() && (millis() - _lastLoopMs < 2)) return; + _lastLoopMs = millis(); + + if (_useHardwareChip) { + _readHardwareChip(); + } + + // Copy shared volatile state into the um_data member variables. + // This is the only place where the volatile→non-volatile copy happens, + // keeping the effect read path simple. + _volumeSmth = s_volumeSmth; + _volumeRaw = s_volumeRaw; + _FFT_MajorPeak = s_FFT_MajorPeak; + _my_magnitude = s_my_magnitude; + _samplePeak = s_samplePeak; + + // Build 16-channel fftResult[] from 7 MSGEQ7 band envelopes via precomputed + // log-frequency interpolation table. + for (int ch = 0; ch < NUM_GEQ_CHANNELS; ch++) { + const GEQInterp &g = s_geqInterp[ch]; + float lo = s_bandEnvelope[g.lo]; + float hi = s_bandEnvelope[g.hi]; + float val = lo + g.t * (hi - lo); + // Clamp and round to uint8_t (0..255). + if (val < 0.0f) val = 0.0f; + if (val > 255.0f) val = 255.0f; + _fftResult[ch] = (uint8_t)val; + } + } + + // ── um_data provider ─────────────────────────────────────────────────────── + + bool getUMData(um_data_t **data) override { + if (!_enabled || !_umData) return false; + *data = _umData; + return true; + } + + // ── Identity ─────────────────────────────────────────────────────────────── + + // Same ID as audioreactive so all existing effects use us transparently. + uint16_t getId() override { return USERMOD_ID_AUDIOREACTIVE; } + + // ── Configuration persistence ────────────────────────────────────────────── + + void addToConfig(JsonObject &root) override { + JsonObject top = root[FPSTR(_name)]; + if (top.isNull()) top = root.createNestedObject(FPSTR(_name)); + + top[F("enabled")] = _enabled; + top[F("useHwChip")] = _useHardwareChip; + top[F("dmType")] = _dmType; + top[F("pinSD")] = _pinSD; + top[F("pinWS")] = _pinWS; + top[F("pinSCK")] = _pinSCK; + top[F("pinMCLK")] = _pinMCLK; + top[F("pinStrobe")] = _pinStrobe; + top[F("pinReset")] = _pinReset; + top[F("pinOut")] = _pinOut; + top[F("gain")] = _gainPercent; + top[F("squelch")] = _squelchLevel; + top[F("filterQ")] = _filterQ; + top[F("attackMs")] = _attackMs; + top[F("decayMs")] = _decayMs; + } + + bool readFromConfig(JsonObject &root) override { + JsonObject top = root[FPSTR(_name)]; + if (top.isNull()) return false; + + bool changed = false; + bool newEnabled = top[F("enabled")] | _enabled; + bool newUseHwChip = top[F("useHwChip")] | _useHardwareChip; + uint8_t newDmType = top[F("dmType")] | _dmType; + int8_t newPinSD = top[F("pinSD")] | _pinSD; + int8_t newPinWS = top[F("pinWS")] | _pinWS; + int8_t newPinSCK = top[F("pinSCK")] | _pinSCK; + int8_t newPinMCLK = top[F("pinMCLK")] | _pinMCLK; + int8_t newStrobe = top[F("pinStrobe")] | _pinStrobe; + int8_t newReset = top[F("pinReset")] | _pinReset; + int8_t newOut = top[F("pinOut")] | _pinOut; + uint8_t newGain = top[F("gain")] | _gainPercent; + uint8_t newSqlch = top[F("squelch")] | _squelchLevel; + float newQ = top[F("filterQ")] | _filterQ; + uint16_t newAtk = top[F("attackMs")] | _attackMs; + uint16_t newDec = top[F("decayMs")] | _decayMs; + + // Any hardware/filter change needs a full reinit. + if (newEnabled != _enabled || newUseHwChip != _useHardwareChip + || newDmType != _dmType || newPinSD != _pinSD || newPinWS != _pinWS + || newPinSCK != _pinSCK || newPinMCLK != _pinMCLK + || newStrobe != _pinStrobe || newReset != _pinReset || newOut != _pinOut + || newQ != _filterQ) { + changed = true; + } + + _enabled = newEnabled; + _useHardwareChip = newUseHwChip; + _dmType = newDmType; + _pinSD = newPinSD; + _pinWS = newPinWS; + _pinSCK = newPinSCK; + _pinMCLK = newPinMCLK; + _pinStrobe = newStrobe; + _pinReset = newReset; + _pinOut = newOut; + _gainPercent = newGain; + _squelchLevel = newSqlch; + _filterQ = newQ; + _attackMs = newAtk; + _decayMs = newDec; + + if (changed && _initDone) { + _stopProcessing(); + if (_enabled) _startProcessing(); + } + + return true; + } + + void appendConfigData() override { + // Append UI helpers via the settings page JS API. + // Labels and select options for dmType, matching audioreactive's mic types. + oappend(SET_F("addInfo('MSGEQ7:dmType',1,'(software backend)');")); + oappend(SET_F("dd=addDropdown('MSGEQ7','dmType');")); + oappend(SET_F("addOption(dd,'Generic I2S',1);")); + oappend(SET_F("addOption(dd,'ES7243',2);")); + oappend(SET_F("addOption(dd,'SPH0645',3);")); + oappend(SET_F("addOption(dd,'I2S + MCLK',4);")); + oappend(SET_F("addOption(dd,'PDM',5);")); + oappend(SET_F("addOption(dd,'ES8388',6);")); + oappend(SET_F("addOption(dd,'ADC (classic ESP32)',0);")); + } + + // ── Info page ────────────────────────────────────────────────────────────── + + void addToJsonInfo(JsonObject &root) override { + JsonObject user = root[F("u")]; + if (user.isNull()) user = root.createNestedObject(F("u")); + JsonArray arr = user.createNestedArray(F("MSGEQ7")); + if (!_enabled) { + arr.add(F("disabled")); + return; + } + const char *backend = _useHardwareChip ? "hw chip" : "sw emulation"; + arr.add(backend); + } + +private: + // ── Private methods ──────────────────────────────────────────────────────── + + void _startProcessing() { + if (_useHardwareChip) { + _initHardwareChip(); + } else { + _initSoftwareBackend(); + } + } + + void _stopProcessing() { + if (!_useHardwareChip && s_swTaskHandle) { + // Signal the task to stop and wait for it to exit. + if (_swTaskParams) _swTaskParams->stop = true; + // Give the task up to 500ms to terminate gracefully. + uint32_t deadline = millis() + 500; + while (s_swTaskHandle != nullptr && millis() < deadline) delay(10); + s_swTaskHandle = nullptr; + } + if (_swTaskParams) { + delete _swTaskParams; + _swTaskParams = nullptr; + } + if (_audioSource) { + _audioSource->deinitialize(); + delete _audioSource; + _audioSource = nullptr; + } + _deinitHardwareChip(); + + // Zero out shared state so effects don't see stale data after disable. + for (int b = 0; b < NUM_BANDS; b++) s_bandEnvelope[b] = 0.0f; + s_volumeSmth = 0.0f; + s_volumeRaw = 0; + s_FFT_MajorPeak = 1.0f; + s_my_magnitude = 0.001f; + s_samplePeak = 0; + } + + // ── Software backend initialisation ────────────────────────────────────── + + void _initSoftwareBackend() { + // Reset I2S peripheral (follows audioreactive pattern). + i2s_driver_uninstall(I2S_NUM_0); +#if !defined(CONFIG_IDF_TARGET_ESP32C3) + delay(100); + periph_module_reset(PERIPH_I2S0_MODULE); +#endif + delay(100); + + // Infer PDM from missing SCK pin on dmType 1 and 4 (same heuristic as AR). +#if !defined(CONFIG_IDF_TARGET_ESP32S2) && !defined(CONFIG_IDF_TARGET_ESP32C3) + if ((_pinSCK == I2S_PIN_NO_CHANGE) && (_pinSD >= 0) && (_pinWS >= 0) + && ((_dmType == 1) || (_dmType == 4))) { + _dmType = 5; + } +#endif + + switch (_dmType) { +#if defined(CONFIG_IDF_TARGET_ESP32S2) || defined(CONFIG_IDF_TARGET_ESP32C3) || defined(CONFIG_IDF_TARGET_ESP32S3) + case 0: // ADC — not supported on S2/C3/S3 +#if defined(CONFIG_IDF_TARGET_ESP32S2) || defined(CONFIG_IDF_TARGET_ESP32C3) + case 5: // PDM — not supported on S2/C3 +#endif +#endif + case 1: + _audioSource = new I2SSource(MSGEQ7_SAMPLE_RATE, MSGEQ7_BLOCK_SIZE); + delay(100); + if (_audioSource) _audioSource->initialize(_pinWS, _pinSD, _pinSCK); + break; + case 2: + _audioSource = new ES7243(MSGEQ7_SAMPLE_RATE, MSGEQ7_BLOCK_SIZE); + delay(100); + if (_audioSource) _audioSource->initialize(_pinWS, _pinSD, _pinSCK, _pinMCLK); + break; + case 3: + _audioSource = new SPH0654(MSGEQ7_SAMPLE_RATE, MSGEQ7_BLOCK_SIZE); + delay(100); + if (_audioSource) _audioSource->initialize(_pinWS, _pinSD, _pinSCK); + break; + case 4: + // I2S with MCLK (line-in boards). Scale factor 1/24 matches audioreactive. + _audioSource = new I2SSource(MSGEQ7_SAMPLE_RATE, MSGEQ7_BLOCK_SIZE, 1.0f/24.0f); + delay(100); + if (_audioSource) _audioSource->initialize(_pinWS, _pinSD, _pinSCK, _pinMCLK); + break; +#if !defined(CONFIG_IDF_TARGET_ESP32S2) && !defined(CONFIG_IDF_TARGET_ESP32C3) + case 5: + _audioSource = new I2SSource(MSGEQ7_SAMPLE_RATE, MSGEQ7_BLOCK_SIZE, 1.0f/4.0f); + delay(100); + if (_audioSource) _audioSource->initialize(_pinWS, _pinSD); + break; +#endif + case 6: + _audioSource = new ES8388Source(MSGEQ7_SAMPLE_RATE, MSGEQ7_BLOCK_SIZE); + delay(100); + if (_audioSource) _audioSource->initialize(_pinWS, _pinSD, _pinSCK, _pinMCLK); + break; +#if !defined(CONFIG_IDF_TARGET_ESP32S2) && !defined(CONFIG_IDF_TARGET_ESP32C3) && !defined(CONFIG_IDF_TARGET_ESP32S3) + case 0: + _audioSource = new I2SAdcSource(MSGEQ7_SAMPLE_RATE, MSGEQ7_BLOCK_SIZE); + delay(100); + if (_audioSource) _audioSource->initialize(_pinSD); + break; +#endif + default: + DEBUG_PRINTLN(F("MSGEQ7: unknown dmType")); + return; + } + + if (!_audioSource || !_audioSource->isInitialized()) { + DEBUG_PRINTLN(F("MSGEQ7: audio source init failed")); + if (_audioSource) { delete _audioSource; _audioSource = nullptr; } + return; + } + + // Compute biquad coefficients for all 7 bands. + initBiquadCoeffs((float)MSGEQ7_SAMPLE_RATE, _filterQ); + + // Compute envelope coefficients from ms time constants and the block rate. + // The envelope is updated once per sample (not per block), so use sample rate. + float attackC = timeConstToCoeff(_attackMs * 0.001f, (float)MSGEQ7_SAMPLE_RATE); + float decayC = timeConstToCoeff(_decayMs * 0.001f, (float)MSGEQ7_SAMPLE_RATE); + float linearGain = (_gainPercent / 128.0f); // 128 = unity gain + + _swTaskParams = new SWTaskParams{ + /*.source =*/ _audioSource, + /*.Q =*/ _filterQ, + /*.gainLevel =*/ linearGain, + /*.squelch =*/ (float)_squelchLevel, + /*.attackCoeff =*/ attackC, + /*.decayCoeff =*/ decayC, + /*.stop =*/ false + }; + + xTaskCreatePinnedToCore( + softwareProcessingTask, + "MSGEQ7", + MSGEQ7_TASK_STACK, + _swTaskParams, + MSGEQ7_TASK_PRIORITY, + &s_swTaskHandle, + 0 // Core 0, same as audioreactive FFT task + ); + } + + // ── Hardware MSGEQ7 chip backend ────────────────────────────────────────── + + void _initHardwareChip() { + if (_pinStrobe < 0 || _pinReset < 0 || _pinOut < 0) { + DEBUG_PRINTLN(F("MSGEQ7: hw chip pins not configured")); + return; + } + if (!PinManager::allocatePin(_pinStrobe, true, PinOwner::UM_Audioreactive) + || !PinManager::allocatePin(_pinReset, true, PinOwner::UM_Audioreactive) + || !PinManager::allocatePin(_pinOut, false, PinOwner::UM_Audioreactive)) { + DEBUG_PRINTLN(F("MSGEQ7: failed to allocate hw chip pins")); + _deinitHardwareChip(); + return; + } + pinMode(_pinStrobe, OUTPUT); + pinMode(_pinReset, OUTPUT); + // OUT is analog input — configure via analogRead (no pinMode needed) + digitalWrite(_pinStrobe, HIGH); + digitalWrite(_pinReset, LOW); + _hwChipReady = true; + } + + void _deinitHardwareChip() { + if (_hwChipReady) { + PinManager::deallocatePin(_pinStrobe, PinOwner::UM_Audioreactive); + PinManager::deallocatePin(_pinReset, PinOwner::UM_Audioreactive); + PinManager::deallocatePin(_pinOut, PinOwner::UM_Audioreactive); + _hwChipReady = false; + } + } + + // Read all 7 bands from the physical MSGEQ7 chip. + // The chip outputs band amplitudes one at a time as an analog voltage. + // Protocol (from MSGEQ7 datasheet): + // 1. Pulse RESET high for ≥100ns to reset the internal mux to band 0. + // 2. For each band: pull STROBE low, wait ≥36 µs, read OUT, pull STROBE high. + // 3. Repeat step 2 for all 7 bands (the mux auto-advances on each strobe). + void _readHardwareChip() { + if (!_hwChipReady) return; + uint32_t now = millis(); + if (now - _lastHwReadMs < MSGEQ7_HW_READ_INTERVAL_MS) return; + _lastHwReadMs = now; + + // Reset internal mux to band 0. + digitalWrite(_pinReset, HIGH); + delayMicroseconds(1); + digitalWrite(_pinReset, LOW); + delayMicroseconds(72); // allow output to settle after reset + + float linearGain = (_gainPercent / 128.0f); + float squelch = (float)_squelchLevel; + float prevLow = s_bandEnvelope[0] + s_bandEnvelope[1]; + + for (int b = 0; b < NUM_BANDS; b++) { + digitalWrite(_pinStrobe, LOW); + delayMicroseconds(36); // strobe-to-output delay + uint16_t adcVal = analogRead(_pinOut); // 12-bit ADC: 0..4095 + digitalWrite(_pinStrobe, HIGH); + delayMicroseconds(36); // hold before next strobe + + // Scale 12-bit ADC value to 0..255. + float val = (adcVal >> 4) * linearGain; // 12→8 bit, then gain + if (val < squelch) val = 0.0f; + if (val > 255.0f) val = 255.0f; + s_bandEnvelope[b] = val; + } + + // Hardware chip output is already log-compressed (analog voltage from chip). + // Compute derived values from the band data. + float envSum = 0.0f; + float envPeak = 0.0f; + int peakBand = 0; + for (int b = 0; b < NUM_BANDS; b++) { + float v = s_bandEnvelope[b]; + envSum += v; + if (v > envPeak) { envPeak = v; peakBand = b; } + } + + // volumeSmth: weighted sum of all bands, normalised. + float volSmth = envSum / (float)NUM_BANDS; + s_volumeSmth = volSmth; + s_volumeRaw = (int16_t)constrain(envPeak, 0.0f, 32767.0f); + s_my_magnitude = envPeak > 0.001f ? envPeak : 0.001f; + + // FFT_MajorPeak with parabolic interpolation (same as software path). + float majorPeak = MSGEQ7_FREQS_HZ[peakBand]; + if (peakBand > 0 && peakBand < NUM_BANDS - 1) { + float a = s_bandEnvelope[peakBand - 1]; + float b_v = s_bandEnvelope[peakBand]; + float c = s_bandEnvelope[peakBand + 1]; + float denom = a - 2.0f * b_v + c; + if (fabsf(denom) > 1e-6f) { + float offset = 0.5f * (a - c) / denom; + float logLo = log10f(MSGEQ7_FREQS_HZ[peakBand > 0 ? peakBand-1 : 0]); + float logHi = log10f(MSGEQ7_FREQS_HZ[peakBand < NUM_BANDS-1 ? peakBand+1 : NUM_BANDS-1]); + float logPk = log10f(MSGEQ7_FREQS_HZ[peakBand]); + float step = (offset >= 0.0f) ? (logHi - logPk) : (logPk - logLo); + majorPeak = powf(10.0f, logPk + offset * step); + } + } + if (majorPeak < 1.0f) majorPeak = 1.0f; + s_FFT_MajorPeak = majorPeak; + + // Beat detection on low bands. + float lowSum = s_bandEnvelope[0] + s_bandEnvelope[1]; + if ((lowSum - prevLow) > MSGEQ7_PEAK_RISE_THRESHOLD * 255.0f && lowSum > squelch) { + s_samplePeak = 1; + } else { + s_samplePeak = 0; + } + } + + // ── Member variables ─────────────────────────────────────────────────────── + + // um_data output variables (these are the actual values effects read via pointers) + float _volumeSmth = 0.0f; + int16_t _volumeRaw = 0; + uint8_t _fftResult[NUM_GEQ_CHANNELS] = {}; + uint8_t _samplePeak = 0; + float _FFT_MajorPeak = 1.0f; + float _my_magnitude = 0.001f; + + // um_data container + um_data_t *_umData = nullptr; + + // Audio source (software backend only) + AudioSource *_audioSource = nullptr; + + // FreeRTOS task params (software backend) + SWTaskParams *_swTaskParams = nullptr; + + // Settings + bool _enabled = false; + bool _useHardwareChip = false; + uint8_t _dmType = 1; // 1 = Generic I2S (default) + int8_t _pinSD = I2S_PIN_NO_CHANGE; + int8_t _pinWS = I2S_PIN_NO_CHANGE; + int8_t _pinSCK = I2S_PIN_NO_CHANGE; + int8_t _pinMCLK = I2S_PIN_NO_CHANGE; + int8_t _pinStrobe = -1; + int8_t _pinReset = -1; + int8_t _pinOut = -1; + uint8_t _gainPercent = 128; // 128 = unity gain + uint8_t _squelchLevel = 8; + float _filterQ = MSGEQ7_DEFAULT_Q; + uint16_t _attackMs = 15; + uint16_t _decayMs = 80; + + // State flags + bool _initDone = false; + bool _hwChipReady = false; + uint32_t _lastHwReadMs = 0; + uint32_t _lastLoopMs = 0; + + // PROGMEM key for config JSON + static const char _name[]; +}; + +const char MSGEQ7Usermod::_name[] PROGMEM = "MSGEQ7"; + +// ─── Registration ───────────────────────────────────────────────────────────── + +static MSGEQ7Usermod msgeq7_usermod; +REGISTER_USERMOD(msgeq7_usermod); + +// AI: end diff --git a/usermods/msgeq7/readme.md b/usermods/msgeq7/readme.md new file mode 100644 index 0000000000..ceda2db1aa --- /dev/null +++ b/usermods/msgeq7/readme.md @@ -0,0 +1,179 @@ +# MSGEQ7 Usermod + +A drop-in replacement for the **audioreactive** usermod that uses a software +implementation of the classic MSGEQ7 seven-band graphic equalizer IC, with +optional support for a physical MSGEQ7 chip. + +Produces the identical `um_data_t` 8-slot structure as audioreactive, so all +existing WLED audio-reactive effects work without modification. + +--- + +## Backends + +### Software emulation (default) + +Captures audio via an I2S or ADC microphone (same hardware as audioreactive), +then runs the samples through **seven second-order IIR bandpass filters** at the +MSGEQ7 center frequencies: + +| Band | Center frequency | +|------|-----------------| +| 0 | 63 Hz | +| 1 | 160 Hz | +| 2 | 400 Hz | +| 3 | 1 000 Hz | +| 4 | 2 500 Hz | +| 5 | 6 250 Hz | +| 6 | 16 000 Hz | + +The filter outputs are peak-hold envelope-detected, log-compressed to match the +real chip's output characteristic, and interpolated to the 16-channel +`fftResult[]` array expected by WLED effects. + +**Sample rate: 44 100 Hz** (required for the 16 kHz band). + +### Hardware chip + +Reads a physical MSGEQ7 IC via three GPIO pins using the standard +strobe/reset/OUT protocol. The chip already outputs log-compressed analog +voltages, so no software compression is applied in this path. + +--- + +## Activation + +Add to your `platformio_override.ini` (do **not** activate alongside +`audioreactive` — both register as `USERMOD_ID_AUDIOREACTIVE`): + +```ini +[env:esp32dev] +custom_usermods = msgeq7 +``` + +--- + +## Hardware wiring + +### Software backend — I2S microphone (INMP441 example) + +``` +INMP441 ESP32 +------- ----- +VDD → 3.3V +GND → GND +L/R → GND (selects left channel) +WS → GPIO 15 (pinWS) +SCK → GPIO 14 (pinSCK) +SD → GPIO 32 (pinSD) +``` + +Configure the matching pins in the WLED settings page under **MSGEQ7**. + +### Hardware chip backend + +``` +MSGEQ7 ESP32 +------- ----- +VDD → 5V (or 3.3V with level shifter on STROBE/RESET) +GND → GND +STROBE → GPIO 33 (pinStrobe, digital output) +RESET → GPIO 27 (pinReset, digital output) +OUT → GPIO 34 (pinOut, ADC1 input only — ADC2 conflicts with WiFi) +AUDIO IN → audio source via coupling capacitor (see MSGEQ7 datasheet) +``` + +> **Important**: `pinOut` must be on **ADC1** (GPIO 32–39 on classic ESP32). +> ADC2 pins are unusable while WiFi is active. + +--- + +## Settings + +| Setting | Description | Default | +|---------|-------------|---------| +| enabled | Enable/disable the usermod | off | +| useHwChip | Use physical MSGEQ7 chip instead of software emulation | off | +| dmType | Microphone type (software backend only) | 1 (Generic I2S) | +| pinSD / pinWS / pinSCK / pinMCLK | I2S pins | unset | +| pinStrobe / pinReset / pinOut | Hardware chip pins | unset | +| gain | Input amplification (128 = unity) | 128 | +| squelch | Noise floor — signals below this level are zeroed | 8 | +| filterQ | Biquad filter Q factor (software backend only) | 1.4 | +| attackMs | Envelope attack time in ms | 15 | +| decayMs | Envelope decay time in ms | 80 | + +### dmType values (software backend) + +| Value | Microphone | +|-------|-----------| +| 0 | ADC analog (classic ESP32 only) | +| 1 | Generic I2S digital (INMP441, ICS-43434, etc.) | +| 2 | ES7243 | +| 3 | SPH0645 | +| 4 | Generic I2S with master clock | +| 5 | PDM | +| 6 | ES8388 (LyraT / AudioKit) | + +--- + +## Comparison with audioreactive + +| Feature | audioreactive | msgeq7 | +|---------|--------------|--------| +| Frequency resolution | 256 FFT bins | 7 bands + parabolic interp | +| External lib dep | arduinoFFT 2.x | **none** | +| RAM (filter buffers) | ~4 KB | ~700 B | +| UDP audio sync | yes | no | +| AGC | PI controller | manual gain slider | +| Dynamic palettes | yes (3 palettes) | no | +| Hardware chip support | no | yes | + +### Effects with degraded output + +- **Rocktaves** — octave detection relies on FFT bin resolution; with 7 bands + the octave separation is coarse and the effect behaviour changes noticeably. +- **FFT_MajorPeak-dependent effects** (Freqmap, Waterfall, Freqwave, Gravfreq, + Ripplepeak) — parabolic interpolation is applied to smooth between bands, but + resolution is lower than full FFT. The effect still works; transitions between + frequency regions are less granular. + +All GEQ/bar-chart effects (2D GEQ, DJ Light, Funky Plank, Blurz, Particle GEQ, +PS GEQ, Noisemove) and volume-driven effects work at full quality. + +--- + +## Validation — sine sweep test + +Enable debug output by adding `-D SR_DEBUG` to your build flags, then run the +included sweep analysis script after capturing serial output: + +```bash +# Capture serial output while a sine sweep plays through the microphone +# (use minicom, screen, or PlatformIO's monitor command) +pio device monitor -e esp32dev > sweep_log.txt + +# Analyse the captured log +python3 usermods/msgeq7/tools/sweep_analyze.py sweep_log.txt +``` + +The script plots band amplitudes vs. time and highlights the expected peak +sequence (band 0 first, band 6 last) for a low-to-high frequency sweep. + +Alternatively, enable `MSGEQ7_DEBUG_SWEEP` in `msgeq7.cpp` (uncomment the +`#define` near the top of `softwareProcessingTask`) to inject a software sine +sweep instead of reading from the microphone. + +--- + +## Source attribution + +Biquad bandpass filter mathematics follow the Audio EQ Cookbook by Robert +Bristow-Johnson: https://www.w3.org/TR/audio-eq-cookbook/ + +MSGEQ7 center frequencies and strobe/reset protocol from the MSGEQ7 datasheet +(Mixed Signal Integration, Inc.). + +Filter processing uses the ESP-IDF `esp-dsp` library +(`dsps_biquad_f32_ae32`/`_aes3`), which is included with the ESP32 Arduino +framework and requires no additional library dependency. diff --git a/usermods/msgeq7/tools/sweep_analyze.py b/usermods/msgeq7/tools/sweep_analyze.py new file mode 100644 index 0000000000..2aef972624 --- /dev/null +++ b/usermods/msgeq7/tools/sweep_analyze.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +""" +sweep_analyze.py — MSGEQ7 usermod band-response analyser. + +Reads a serial log produced while a sine sweep (low → high frequency) is +played through the microphone, parses the MSGEQ7 band debug lines, and plots +band amplitudes over time to verify the filter chain is working correctly. + +Expected serial log format (emitted when SR_DEBUG is defined): + MSGEQ7 bands: 0=NN 1=NN 2=NN 3=NN 4=NN 5=NN 6=NN + +Usage: + python3 sweep_analyze.py + +Requirements: + pip install matplotlib numpy + +Output: + - A stacked band-amplitude plot (bands 0-6 vs. time). + - A summary of which band peaked when, and whether the peak order matches + the expected low-to-high sweep sequence. + +If matplotlib is not available the script still prints a text-mode summary. +""" + +import re +import sys +from pathlib import Path + +# MSGEQ7 center frequencies for axis labels +MSGEQ7_FREQS = [63, 160, 400, 1000, 2500, 6250, 16000] +NUM_BANDS = 7 + +# Regex matching the debug line printed by softwareProcessingTask when +# MSGEQ7_DEBUG_PRINT is defined, e.g.: +# MSGEQ7 bands: 0=127 1=43 2=8 3=2 4=1 5=0 6=0 +_LINE_RE = re.compile( + r"MSGEQ7 bands:\s+" + r"\s+".join(rf"{b}=(\d+)" for b in range(NUM_BANDS)) +) + + +def parse_log(path: Path) -> list[list[int]]: + """Return a list of frames, each frame being a list of 7 band values.""" + frames = [] + with path.open(errors="replace") as fh: + for line in fh: + m = _LINE_RE.search(line) + if m: + frames.append([int(m.group(b + 1)) for b in range(NUM_BANDS)]) + return frames + + +def peak_order_check(frames: list[list[int]]) -> None: + """ + Determine the time index at which each band reached its maximum amplitude + and check whether the order is monotonically increasing (band 0 peaks + first, band 6 peaks last), as expected for a low-to-high sine sweep. + """ + if not frames: + print("No data frames found. Check log format.") + return + + n = len(frames) + peak_times = [] + for b in range(NUM_BANDS): + col = [frames[i][b] for i in range(n)] + peak_idx = col.index(max(col)) + peak_val = col[peak_idx] + peak_times.append((peak_idx, peak_val, b)) + print(f" Band {b} ({MSGEQ7_FREQS[b]:>6} Hz): peak={peak_val:>3} " + f"at frame {peak_idx:>4} / {n}") + + ordered = all( + peak_times[i][0] <= peak_times[i + 1][0] for i in range(NUM_BANDS - 1) + ) + print() + if ordered: + print("PASS: bands peaked in correct low→high order.") + else: + print("FAIL: band peak order does not match expected low→high sequence.") + print(" (This is normal for music; expected only for a sine sweep test.)") + + +def plot(frames: list[list[int]]) -> None: + try: + import matplotlib.pyplot as plt # type: ignore + import numpy as np # type: ignore + except ImportError: + print("matplotlib/numpy not available — skipping plot (text summary only).") + return + + data = np.array(frames, dtype=float) # shape (frames, 7) + t = np.arange(len(frames)) + + fig, axes = plt.subplots(NUM_BANDS, 1, figsize=(12, 10), sharex=True) + fig.suptitle("MSGEQ7 Band Amplitudes vs. Time", fontsize=13) + + colors = plt.cm.plasma(np.linspace(0.1, 0.9, NUM_BANDS)) + + for b, ax in enumerate(axes): + ax.fill_between(t, data[:, b], alpha=0.7, color=colors[b]) + ax.set_ylabel(f"{MSGEQ7_FREQS[b]} Hz", fontsize=8, rotation=0, + labelpad=45, va="center") + ax.set_ylim(0, 260) + ax.set_yticks([0, 128, 255]) + ax.grid(axis="x", linestyle=":", alpha=0.4) + + axes[-1].set_xlabel("Frame index") + plt.tight_layout() + plt.show() + + +def main() -> int: + if len(sys.argv) < 2: + print(f"Usage: {sys.argv[0]} ") + return 1 + + path = Path(sys.argv[1]) + if not path.exists(): + print(f"File not found: {path}") + return 1 + + frames = parse_log(path) + print(f"Parsed {len(frames)} band frames from {path}\n") + + if not frames: + print("No 'MSGEQ7 bands:' lines found.") + print("Make sure the firmware was built with -D SR_DEBUG and the log") + print("was captured while audio was playing.") + return 1 + + peak_order_check(frames) + plot(frames) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From db33b297d95ba88276e084429aad1cd64ae54688 Mon Sep 17 00:00:00 2001 From: Will Tatam Date: Sun, 7 Jun 2026 11:11:06 +0100 Subject: [PATCH 2/5] Fix msgeq7 bugs: volatile task handle, type mismatch, inaccurate readme - s_swTaskHandle was not volatile, allowing the compiler to optimize away the polling loop in _stopProcessing(). Now declared volatile. - The task never nulled s_swTaskHandle before vTaskDelete(), so _stopProcessing() always timed out at 500 ms even though the task exited within ~1 ms. Now nulled in-task before self-deletion. - xTaskCreatePinnedToCore() requires a non-volatile TaskHandle_t*; use a local temporary and store it to the volatile after the call. - s_volumeRaw / _volumeRaw typed int16_t but registered as UMT_UINT16; changed to uint16_t throughout for consistency with the slot type. - Remove readme paragraph referencing MSGEQ7_DEBUG_SWEEP, a define that was never implemented. --- usermods/msgeq7/msgeq7.cpp | 18 ++++++++++++------ usermods/msgeq7/readme.md | 4 ---- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/usermods/msgeq7/msgeq7.cpp b/usermods/msgeq7/msgeq7.cpp index d82e6bff00..a8d118de07 100644 --- a/usermods/msgeq7/msgeq7.cpp +++ b/usermods/msgeq7/msgeq7.cpp @@ -101,7 +101,7 @@ static constexpr float MSGEQ7_PEAK_RISE_THRESHOLD = 0.15f; static volatile float s_bandEnvelope[NUM_BANDS] = {}; // 0.0–255.0 per band static volatile float s_volumeSmth = 0.0f; -static volatile int16_t s_volumeRaw = 0; +static volatile uint16_t s_volumeRaw = 0; static volatile float s_FFT_MajorPeak = 1.0f; static volatile float s_my_magnitude = 0.001f; static volatile uint8_t s_samplePeak = 0; @@ -225,7 +225,7 @@ struct SWTaskParams { volatile bool stop; // set true to request task exit }; -static TaskHandle_t s_swTaskHandle = nullptr; +static volatile TaskHandle_t s_swTaskHandle = nullptr; // AI: below section was generated by an AI static void IRAM_ATTR softwareProcessingTask(void *pvParams) { @@ -371,7 +371,7 @@ static void IRAM_ATTR softwareProcessingTask(void *pvParams) { float normRMS = blockRMS / 32768.0f * p->gainLevel; // float samples are ~[-32768..32768] float volSmth = 255.0f * (log10f(1.0f + MSGEQ7_LOG_SCALE * constrain(normRMS, 0.0f, 1.0f)) / log10f(1.0f + MSGEQ7_LOG_SCALE)); - int16_t volRaw = (int16_t)constrain(blockPeak * p->gainLevel / 128.0f, 0.0f, 32767.0f); + uint16_t volRaw = (uint16_t)constrain(blockPeak * p->gainLevel / 128.0f, 0.0f, 65535.0f); // --- Debug output for sweep_analyze.py (compiled out unless SR_DEBUG) --- #ifdef SR_DEBUG @@ -392,6 +392,8 @@ static void IRAM_ATTR softwareProcessingTask(void *pvParams) { free(inputBuf); free(filteredBuf); + // Signal _stopProcessing() that we have fully exited before the task is deleted. + s_swTaskHandle = nullptr; vTaskDelete(nullptr); } // AI: end @@ -716,15 +718,19 @@ class MSGEQ7Usermod : public Usermod { /*.stop =*/ false }; + // xTaskCreatePinnedToCore requires a non-volatile TaskHandle_t*. + // Write through a temporary then store to the volatile handle. + TaskHandle_t handle = nullptr; xTaskCreatePinnedToCore( softwareProcessingTask, "MSGEQ7", MSGEQ7_TASK_STACK, _swTaskParams, MSGEQ7_TASK_PRIORITY, - &s_swTaskHandle, + &handle, 0 // Core 0, same as audioreactive FFT task ); + s_swTaskHandle = handle; } // ── Hardware MSGEQ7 chip backend ────────────────────────────────────────── @@ -808,7 +814,7 @@ class MSGEQ7Usermod : public Usermod { // volumeSmth: weighted sum of all bands, normalised. float volSmth = envSum / (float)NUM_BANDS; s_volumeSmth = volSmth; - s_volumeRaw = (int16_t)constrain(envPeak, 0.0f, 32767.0f); + s_volumeRaw = (uint16_t)constrain(envPeak, 0.0f, 65535.0f); s_my_magnitude = envPeak > 0.001f ? envPeak : 0.001f; // FFT_MajorPeak with parabolic interpolation (same as software path). @@ -843,7 +849,7 @@ class MSGEQ7Usermod : public Usermod { // um_data output variables (these are the actual values effects read via pointers) float _volumeSmth = 0.0f; - int16_t _volumeRaw = 0; + uint16_t _volumeRaw = 0; uint8_t _fftResult[NUM_GEQ_CHANNELS] = {}; uint8_t _samplePeak = 0; float _FFT_MajorPeak = 1.0f; diff --git a/usermods/msgeq7/readme.md b/usermods/msgeq7/readme.md index ceda2db1aa..be919e0d60 100644 --- a/usermods/msgeq7/readme.md +++ b/usermods/msgeq7/readme.md @@ -160,10 +160,6 @@ python3 usermods/msgeq7/tools/sweep_analyze.py sweep_log.txt The script plots band amplitudes vs. time and highlights the expected peak sequence (band 0 first, band 6 last) for a low-to-high frequency sweep. -Alternatively, enable `MSGEQ7_DEBUG_SWEEP` in `msgeq7.cpp` (uncomment the -`#define` near the top of `softwareProcessingTask`) to inject a software sine -sweep instead of reading from the microphone. - --- ## Source attribution From 1157e1f2a13793c5c530b9c132da7ea7dd790d05 Mon Sep 17 00:00:00 2001 From: Will Tatam Date: Sun, 7 Jun 2026 11:24:52 +0100 Subject: [PATCH 3/5] Fix msgeq7 spec compliance: absolute amplitude, real-chip Q, full ADC resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Software backend now scales envelopes against a fixed full-scale reference (int16 32768) instead of per-frame band peak. Preserves absolute amplitude so quiet music stays quiet, matching audioreactive's fftResult[] semantics and what every WLED audio-reactive effect expects. - Move squelch from pre-compression (compared against int16-scale envelope — effectively dead code at the default value) to post-compression on the user-facing 0..255 scale. Update default 8 -> 10 to match audioreactive. - Default filter Q changed 1.4 -> 1.0 to match the real MSGEQ7 chip's ~1.32-octave band spacing (Q = sqrt(2^N)/(2^N-1) with N=1.32). - Hardware backend: scale 12-bit ADC with the full 0..4095 range before truncating to 0..255, instead of dropping the bottom 4 bits with >>4. --- usermods/msgeq7/msgeq7.cpp | 60 +++++++++++++++++++++++--------------- usermods/msgeq7/readme.md | 4 +-- 2 files changed, 39 insertions(+), 25 deletions(-) diff --git a/usermods/msgeq7/msgeq7.cpp b/usermods/msgeq7/msgeq7.cpp index a8d118de07..296b389f73 100644 --- a/usermods/msgeq7/msgeq7.cpp +++ b/usermods/msgeq7/msgeq7.cpp @@ -68,9 +68,12 @@ static constexpr float MSGEQ7_FREQS_HZ[NUM_BANDS] = { 63.0f, 160.0f, 400.0f, 1000.0f, 2500.0f, 6250.0f, 16000.0f }; -// Default filter Q (quality factor). Q ≈ 1.4 gives ~one-octave 3dB bandwidth per -// band, similar to the real MSGEQ7 chip's overlapping filter character. -static constexpr float MSGEQ7_DEFAULT_Q = 1.4f; +// Default filter Q (quality factor). Real MSGEQ7 bands are spaced by a factor +// of 2.5 (≈1.32 octaves) and overlap noticeably; the corresponding Q for +// non-overlapping bandpasses at this spacing is Q = sqrt(2^N)/(2^N − 1) ≈ 1.05 +// with N = 1.32 octaves. Q = 1.0 closely matches the chip's response — slightly +// wider than 1.05 to give modest band overlap as the real chip exhibits. +static constexpr float MSGEQ7_DEFAULT_Q = 1.0f; // Envelope detector time constants (seconds). // Approximate real MSGEQ7 peak-hold behaviour: fast attack, slow decay. @@ -286,6 +289,8 @@ static void IRAM_ATTR softwareProcessingTask(void *pvParams) { #endif // Half-wave rectify (abs) then update peak-hold envelope. + // Note: NO squelch here — squelch is applied to the compressed output + // below, where the user-facing 0..255 scale lives. float attackC = p->attackCoeff; float decayC = p->decayCoeff; float env = envelope[b]; @@ -297,26 +302,33 @@ static void IRAM_ATTR softwareProcessingTask(void *pvParams) { env += (absVal - env) * decayC; } } - // Apply squelch: silence very faint signals to avoid noise floor crawl. - if (env < p->squelch) env = 0.0f; envelope[b] = env; } - // --- Log-compress envelopes to 0..255 scale (matches real MSGEQ7 output) --- - // The compression function: out = 255 * log10(1 + 9*in/peak) / log10(10) - // where `in` is normalised against the current envelope peak. A global - // normalisation factor avoids bands dominating purely due to mic sensitivity. - float envPeak = 0.0f; - for (int b = 0; b < NUM_BANDS; b++) { - if (envelope[b] > envPeak) envPeak = envelope[b]; - } - + // --- Log-compress envelopes to 0..255 ABSOLUTE scale --- + // The real MSGEQ7 chip outputs an analog voltage proportional to the + // peak-detected band amplitude — quiet sounds → small voltage, loud → large. + // We therefore normalise against the full int16 sample range (a fixed + // reference) — NOT against the per-frame band peak — so absolute amplitude + // is preserved. This is what audioreactive's fftResult[] does and what + // every WLED audio-reactive effect expects. + // + // Compression curve: out = 255 * log10(1 + 9*x) / log10(10), x = env/32768 + // Maps linear 0..1 → 0..255 with greater resolution at low levels, similar + // to the chip's logarithmic output. + static constexpr float kFullScale = 32768.0f; // float-int16 full range + // Pre-computed log10(1 + MSGEQ7_LOG_SCALE) — used to normalise the curve so + // a unity input maps to 255. Stays correct if MSGEQ7_LOG_SCALE is changed. + const float kLogDivisor = log10f(1.0f + MSGEQ7_LOG_SCALE); float compressedEnv[NUM_BANDS]; for (int b = 0; b < NUM_BANDS; b++) { - float normalised = (envPeak > 1e-6f) ? (envelope[b] / envPeak) : 0.0f; - // log10(1 + 9*x) / log10(10) maps [0,1] → [0,1] with log curve - compressedEnv[b] = 255.0f * (log10f(1.0f + MSGEQ7_LOG_SCALE * normalised) - / log10f(1.0f + MSGEQ7_LOG_SCALE)); + float normalised = envelope[b] / kFullScale; // 0..1+ (clamped below) + if (normalised < 0.0f) normalised = 0.0f; + if (normalised > 1.0f) normalised = 1.0f; + float c = 255.0f * (log10f(1.0f + MSGEQ7_LOG_SCALE * normalised) / kLogDivisor); + // Apply user-facing noise gate on the output 0..255 scale (matches UI). + if (c < p->squelch) c = 0.0f; + compressedEnv[b] = c; } // --- Beat / samplePeak detection --- @@ -788,13 +800,15 @@ class MSGEQ7Usermod : public Usermod { for (int b = 0; b < NUM_BANDS; b++) { digitalWrite(_pinStrobe, LOW); - delayMicroseconds(36); // strobe-to-output delay + delayMicroseconds(36); // strobe-to-output delay (Tso) uint16_t adcVal = analogRead(_pinOut); // 12-bit ADC: 0..4095 digitalWrite(_pinStrobe, HIGH); - delayMicroseconds(36); // hold before next strobe + delayMicroseconds(36); // hold before next strobe (Tsh ≥ 18µs) - // Scale 12-bit ADC value to 0..255. - float val = (adcVal >> 4) * linearGain; // 12→8 bit, then gain + // Scale 12-bit ADC (0..4095) to 0..255 using the full ADC resolution + // before truncating, then apply user gain. Avoids the 4-bit precision + // loss of `>>4` — small differences near the noise floor stay visible. + float val = (adcVal * (255.0f / 4095.0f)) * linearGain; if (val < squelch) val = 0.0f; if (val > 255.0f) val = 255.0f; s_bandEnvelope[b] = val; @@ -876,7 +890,7 @@ class MSGEQ7Usermod : public Usermod { int8_t _pinReset = -1; int8_t _pinOut = -1; uint8_t _gainPercent = 128; // 128 = unity gain - uint8_t _squelchLevel = 8; + uint8_t _squelchLevel = 10; // noise gate on 0..255 output scale float _filterQ = MSGEQ7_DEFAULT_Q; uint16_t _attackMs = 15; uint16_t _decayMs = 80; diff --git a/usermods/msgeq7/readme.md b/usermods/msgeq7/readme.md index be919e0d60..29cddb9cdd 100644 --- a/usermods/msgeq7/readme.md +++ b/usermods/msgeq7/readme.md @@ -98,8 +98,8 @@ AUDIO IN → audio source via coupling capacitor (see MSGEQ7 datasheet) | pinSD / pinWS / pinSCK / pinMCLK | I2S pins | unset | | pinStrobe / pinReset / pinOut | Hardware chip pins | unset | | gain | Input amplification (128 = unity) | 128 | -| squelch | Noise floor — signals below this level are zeroed | 8 | -| filterQ | Biquad filter Q factor (software backend only) | 1.4 | +| squelch | Noise floor on 0..255 output scale — signals below this are zeroed | 10 | +| filterQ | Biquad filter Q factor (software backend only) | 1.0 | | attackMs | Envelope attack time in ms | 15 | | decayMs | Envelope decay time in ms | 80 | From 975fa712c3ea1d0435fe2dc7d7403bbf654be317 Mon Sep 17 00:00:00 2001 From: Will Tatam Date: Sun, 7 Jun 2026 12:03:26 +0100 Subject: [PATCH 4/5] Refactor msgeq7: split DSP engine into msgeq7_engine.h MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move all signal processing and hardware protocol code out of the usermod class into a standalone header (msgeq7_engine.h), leaving msgeq7.cpp as thin WLED-specific glue only. msgeq7_engine.h now owns: - All MSGEQ7 constants - Shared volatile output state (s_bandEnvelope, s_volumeSmth, etc.) - 7→16 channel GEQ interpolation table (buildInterpolationTable) - Biquad bandpass filter bank (initBiquadCoeffs, timeConstToCoeff) - FreeRTOS software processing task (softwareProcessingTask) - Physical chip GPIO protocol (msgeq7_hw_gpio_init, msgeq7_hw_read) msgeq7.cpp retains: - AudioSource construction (I2S/ADC driver setup, audio_source.h types) - PinManager pin reservation/release - um_data_t registration and WLED effect API - JSON config persistence and web UI helpers This makes the engine code self-contained and easier to transplant into the audioreactive usermod if the PoC proves successful. --- usermods/msgeq7/msgeq7.cpp | 660 +++++--------------------------- usermods/msgeq7/msgeq7_engine.h | 465 ++++++++++++++++++++++ 2 files changed, 567 insertions(+), 558 deletions(-) create mode 100644 usermods/msgeq7/msgeq7_engine.h diff --git a/usermods/msgeq7/msgeq7.cpp b/usermods/msgeq7/msgeq7.cpp index 296b389f73..516073bdfb 100644 --- a/usermods/msgeq7/msgeq7.cpp +++ b/usermods/msgeq7/msgeq7.cpp @@ -1,14 +1,14 @@ /* - * msgeq7 — Software MSGEQ7 audio-reactive usermod for WLED + * msgeq7.cpp — WLED usermod wrapper for the MSGEQ7 audio-reactive engine * - * Produces the same um_data_t 8-slot structure as the audioreactive usermod, - * making it a drop-in replacement. Uses either: - * (A) Software backend: I2S/ADC microphone + 7 biquad bandpass filters at the - * classic MSGEQ7 center frequencies (63, 160, 400, 1k, 2.5k, 6.25k, 16k Hz). - * (B) Hardware backend: physical MSGEQ7 chip read via strobe/reset/OUT pins. + * Thin integration layer connecting the MSGEQ7 DSP engine (msgeq7_engine.h) + * to the WLED usermod API. Responsible for: + * - AudioSource initialisation (I2S / ADC driver setup via audio_source.h) + * - GPIO pin reservation via WLED's PinManager + * - um_data_t registration so audio-reactive effects can consume the data + * - Settings persistence (JSON config, web UI helpers) * - * Both backends produce 7 band amplitudes which are then interpolated to fill - * the 16-channel fftResult[] array expected by WLED effects. + * All signal processing and hardware protocol code lives in msgeq7_engine.h. * * Registers as USERMOD_ID_AUDIOREACTIVE (32) so all existing audio-reactive * effects work without modification. Cannot be loaded simultaneously with the @@ -43,374 +43,9 @@ using FFTsampleType = float; #endif #include "audio_source.h" +#include "msgeq7_engine.h" -#include // esp-dsp: coefficient generation -#include // esp-dsp: filter processing - -// ─── Constants ─────────────────────────────────────────────────────────────── - -// Software backend sample rate. Must be ≥ 32000 Hz to represent the 16 kHz band. -// 44100 Hz is the standard CD audio rate and gives a comfortable margin above 16 kHz. -static constexpr SRate_t MSGEQ7_SAMPLE_RATE = 44100; - -// I2S DMA block size (samples per read). Chosen to match audioreactive. -static constexpr int MSGEQ7_BLOCK_SIZE = 128; - -// Number of MSGEQ7 frequency bands. -static constexpr int NUM_BANDS = 7; - -// Number of GEQ output channels expected by WLED effects. -static constexpr int NUM_GEQ_CHANNELS = 16; - -// Center frequencies of the 7 MSGEQ7 bandpass filters (Hz). -// Source: MSGEQ7 datasheet (Mixed Signal Integration MSI, Inc.). -static constexpr float MSGEQ7_FREQS_HZ[NUM_BANDS] = { - 63.0f, 160.0f, 400.0f, 1000.0f, 2500.0f, 6250.0f, 16000.0f -}; - -// Default filter Q (quality factor). Real MSGEQ7 bands are spaced by a factor -// of 2.5 (≈1.32 octaves) and overlap noticeably; the corresponding Q for -// non-overlapping bandpasses at this spacing is Q = sqrt(2^N)/(2^N − 1) ≈ 1.05 -// with N = 1.32 octaves. Q = 1.0 closely matches the chip's response — slightly -// wider than 1.05 to give modest band overlap as the real chip exhibits. -static constexpr float MSGEQ7_DEFAULT_Q = 1.0f; - -// Envelope detector time constants (seconds). -// Approximate real MSGEQ7 peak-hold behaviour: fast attack, slow decay. -static constexpr float MSGEQ7_ATTACK_SEC = 0.015f; // 15 ms attack -static constexpr float MSGEQ7_DECAY_SEC = 0.080f; // 80 ms decay - -// Log compression: output = 255 * log10(1 + MSGEQ7_LOG_SCALE * normalised_input) -// MSGEQ7_LOG_SCALE = 9 maps linear 0..1 → compressed 0..1 with more low-signal -// resolution, matching the real chip's logarithmic output characteristic. -static constexpr float MSGEQ7_LOG_SCALE = 9.0f; - -// FreeRTOS task settings for the software processing task. -static constexpr uint8_t MSGEQ7_TASK_PRIORITY = 1; -static constexpr uint32_t MSGEQ7_TASK_STACK = 3072; // bytes - -// Minimum ms between hardware MSGEQ7 chip reads. The chip needs ~36 µs per band -// strobe, so 20 ms gives ~50 Hz update rate which is plenty. -static constexpr uint32_t MSGEQ7_HW_READ_INTERVAL_MS = 20; - -// Beat/peak detection: set samplePeak when the rate of rise of the sum of the -// two lowest bands exceeds this fraction of the current peak. -static constexpr float MSGEQ7_PEAK_RISE_THRESHOLD = 0.15f; - -// ─── Global shared state (written by task/ISR, read by main loop) ──────────── -// Protected conceptually by the single-writer/single-reader property: -// the processing task is the sole writer; loop() is the sole reader. -// A memory barrier via volatile is sufficient here — no mutex needed. - -static volatile float s_bandEnvelope[NUM_BANDS] = {}; // 0.0–255.0 per band -static volatile float s_volumeSmth = 0.0f; -static volatile uint16_t s_volumeRaw = 0; -static volatile float s_FFT_MajorPeak = 1.0f; -static volatile float s_my_magnitude = 0.001f; -static volatile uint8_t s_samplePeak = 0; - -// Written by effects, read by effects (via um_data pointers). These are -// intentionally writable from effect code (see audioreactive comment in setup()). -static uint8_t s_maxVol = 31; -static uint8_t s_binNum = 8; - -// ─── Interpolation table: 7 MSGEQ7 bands → 16 GEQ channels ───────────────── -// -// Each of the 16 GEQ output channels is assigned a representative frequency -// derived from audioreactive's FFT bin groupings at 22050 Hz. We then locate -// the two bracketing MSGEQ7 bands and store a linear interpolation weight in -// log-frequency space. -// -// This table is computed once in setup() and used every loop iteration. - -struct GEQInterp { - uint8_t lo; // lower MSGEQ7 band index - uint8_t hi; // upper MSGEQ7 band index (lo+1, clamped to NUM_BANDS-1) - float t; // interpolation weight towards hi: output = lo*(1-t) + hi*t -}; - -static GEQInterp s_geqInterp[NUM_GEQ_CHANNELS]; - -// Representative center frequencies for the 16 GEQ channels (Hz). -// Derived from audioreactive's fftAddAvg() bin ranges at 22050 Hz / 512 bins -// (43.07 Hz per bin). Center = (from + to) / 2 * 43.07. -// Extended to the 44100 Hz range for channels 14-15. -static const float GEQ_TARGET_FREQS_HZ[NUM_GEQ_CHANNELS] = { - 64.6f, 107.7f, 172.3f, 258.4f, - 344.5f, 472.8f, 667.9f, 926.0f, - 1291.0f, 1829.7f, 2561.6f, 3638.3f, - 5229.9f, 7468.5f, 9117.0f, 12903.0f -}; - -// Precompute s_geqInterp[] from GEQ_TARGET_FREQS_HZ and MSGEQ7_FREQS_HZ. -// Must be called once before the first use of the interpolation table. -static void buildInterpolationTable(void) { - // Use log10 scale so that equal perceptual frequency distances map linearly. - float logBands[NUM_BANDS]; - for (int b = 0; b < NUM_BANDS; b++) { - logBands[b] = log10f(MSGEQ7_FREQS_HZ[b]); - } - - for (int ch = 0; ch < NUM_GEQ_CHANNELS; ch++) { - float logTarget = log10f(GEQ_TARGET_FREQS_HZ[ch]); - - // Find the two MSGEQ7 bands that bracket this frequency in log space. - int lo = 0; - for (int b = 0; b < NUM_BANDS - 1; b++) { - if (logBands[b] <= logTarget) lo = b; - } - int hi = lo + 1; - if (hi >= NUM_BANDS) hi = NUM_BANDS - 1; - - float t = 0.0f; - if (hi != lo) { - t = (logTarget - logBands[lo]) / (logBands[hi] - logBands[lo]); - // Clamp to [0,1] so out-of-range frequencies saturate at the nearest band. - if (t < 0.0f) t = 0.0f; - if (t > 1.0f) t = 1.0f; - } - - s_geqInterp[ch].lo = (uint8_t)lo; - s_geqInterp[ch].hi = (uint8_t)hi; - s_geqInterp[ch].t = t; - } -} - -// ─── Software backend: biquad filter chain ─────────────────────────────────── - -// esp-dsp biquad requires 16-byte-aligned coefficient and delay-line arrays -// when using the ae32/aes3 SIMD variants. -// Layout per filter: float coeffs[5]={b0,b1,b2,a1,a2}, float w[2]={w0,w1}. -// We store them together so each filter state is contiguous. - -struct alignas(16) BandFilter { - float coeffs[5]; // biquad coefficients produced by dsps_biquad_gen_bpf_f32() - float w[2]; // IIR delay line (initialised to zero; written by filter) -}; - -static BandFilter s_filters[NUM_BANDS]; - -// Compute biquad coefficients for all 7 bands at the given Q and sample rate. -// Called from setup() and whenever Q changes. -static void initBiquadCoeffs(float sampleRate, float Q) { - for (int b = 0; b < NUM_BANDS; b++) { - // Normalised frequency for esp-dsp: f = fc / fs, expected range [0 .. 0.5]. - // The 16 kHz band at 44100 Hz gives f = 0.363 — well within range. - float f = MSGEQ7_FREQS_HZ[b] / sampleRate; - if (f >= 0.5f) f = 0.499f; // hard clamp below Nyquist - dsps_biquad_gen_bpf_f32(s_filters[b].coeffs, f, Q); - s_filters[b].w[0] = 0.0f; - s_filters[b].w[1] = 0.0f; - } -} - -// Compute one-pole attack/decay IIR coefficients from time constants. -// coeff = 1 - exp(-1 / (timeConst_s * sampleRate)) -// For large timeConst the result is near 0 (slow); for small, near 1 (fast). -static float timeConstToCoeff(float timeConst_s, float sampleRate) { - return 1.0f - expf(-1.0f / (timeConst_s * sampleRate)); -} - -// ─── Software processing task ──────────────────────────────────────────────── -// -// Runs on Core 0 with MSGEQ7_TASK_PRIORITY. -// Reads I2S samples in blocks, runs each block through all 7 biquad filters, -// updates envelope detectors, and writes the results to the shared volatile -// state variables. - -struct SWTaskParams { - AudioSource *source; - float Q; - float gainLevel; // linear multiplier applied to raw samples - float squelch; // below this raw magnitude, force envelope to zero - float attackCoeff; // computed from MSGEQ7_ATTACK_SEC - float decayCoeff; // computed from MSGEQ7_DECAY_SEC - volatile bool stop; // set true to request task exit -}; - -static volatile TaskHandle_t s_swTaskHandle = nullptr; - -// AI: below section was generated by an AI -static void IRAM_ATTR softwareProcessingTask(void *pvParams) { - SWTaskParams *p = static_cast(pvParams); - - // Allocate working buffers on the task stack is too risky (3 KB stack). - // Use heap instead. Both buffers hold one I2S block. - float *inputBuf = static_cast(malloc(MSGEQ7_BLOCK_SIZE * sizeof(float))); - float *filteredBuf = static_cast(malloc(MSGEQ7_BLOCK_SIZE * sizeof(float))); - - if (!inputBuf || !filteredBuf) { - free(inputBuf); - free(filteredBuf); - vTaskDelete(nullptr); - return; - } - - float envelope[NUM_BANDS] = {}; // local copy, written back to shared state - float prevLowBandSum = 0.0f; // for peak (beat) detection - float peakHoldTimer = 0.0f; // counts down to auto-clear samplePeak - - const TickType_t xFrequency = pdMS_TO_TICKS(1); // yield every ms (WDT feed) - - while (!p->stop) { - delay(1); // feeds IDLE(0) watchdog — do not remove; see audioreactive comment - - if (!p->source || !p->source->isInitialized()) { - vTaskDelay(xFrequency); - continue; - } - - // --- Read one block of audio samples from I2S DMA --- - p->source->getSamples(inputBuf, MSGEQ7_BLOCK_SIZE); - - // --- Apply input gain and compute raw block peak (for volumeRaw) --- - float blockPeak = 0.0f; - float blockSumSq = 0.0f; - for (int i = 0; i < MSGEQ7_BLOCK_SIZE; i++) { - inputBuf[i] *= p->gainLevel; - float absVal = fabsf(inputBuf[i]); - if (absVal > blockPeak) blockPeak = absVal; - blockSumSq += absVal * absVal; - } - float blockRMS = sqrtf(blockSumSq / MSGEQ7_BLOCK_SIZE); - - // --- Run each biquad bandpass filter over the block, update envelope --- - for (int b = 0; b < NUM_BANDS; b++) { - // Select the best available SIMD variant at compile time. -#if dsps_biquad_f32_aes3_enabled - dsps_biquad_f32_aes3(inputBuf, filteredBuf, MSGEQ7_BLOCK_SIZE, - s_filters[b].coeffs, s_filters[b].w); -#elif dsps_biquad_f32_ae32_enabled - dsps_biquad_f32_ae32(inputBuf, filteredBuf, MSGEQ7_BLOCK_SIZE, - s_filters[b].coeffs, s_filters[b].w); -#else - dsps_biquad_f32_ansi(inputBuf, filteredBuf, MSGEQ7_BLOCK_SIZE, - s_filters[b].coeffs, s_filters[b].w); -#endif - - // Half-wave rectify (abs) then update peak-hold envelope. - // Note: NO squelch here — squelch is applied to the compressed output - // below, where the user-facing 0..255 scale lives. - float attackC = p->attackCoeff; - float decayC = p->decayCoeff; - float env = envelope[b]; - for (int i = 0; i < MSGEQ7_BLOCK_SIZE; i++) { - float absVal = fabsf(filteredBuf[i]); - if (absVal > env) { - env += (absVal - env) * attackC; - } else { - env += (absVal - env) * decayC; - } - } - envelope[b] = env; - } - - // --- Log-compress envelopes to 0..255 ABSOLUTE scale --- - // The real MSGEQ7 chip outputs an analog voltage proportional to the - // peak-detected band amplitude — quiet sounds → small voltage, loud → large. - // We therefore normalise against the full int16 sample range (a fixed - // reference) — NOT against the per-frame band peak — so absolute amplitude - // is preserved. This is what audioreactive's fftResult[] does and what - // every WLED audio-reactive effect expects. - // - // Compression curve: out = 255 * log10(1 + 9*x) / log10(10), x = env/32768 - // Maps linear 0..1 → 0..255 with greater resolution at low levels, similar - // to the chip's logarithmic output. - static constexpr float kFullScale = 32768.0f; // float-int16 full range - // Pre-computed log10(1 + MSGEQ7_LOG_SCALE) — used to normalise the curve so - // a unity input maps to 255. Stays correct if MSGEQ7_LOG_SCALE is changed. - const float kLogDivisor = log10f(1.0f + MSGEQ7_LOG_SCALE); - float compressedEnv[NUM_BANDS]; - for (int b = 0; b < NUM_BANDS; b++) { - float normalised = envelope[b] / kFullScale; // 0..1+ (clamped below) - if (normalised < 0.0f) normalised = 0.0f; - if (normalised > 1.0f) normalised = 1.0f; - float c = 255.0f * (log10f(1.0f + MSGEQ7_LOG_SCALE * normalised) / kLogDivisor); - // Apply user-facing noise gate on the output 0..255 scale (matches UI). - if (c < p->squelch) c = 0.0f; - compressedEnv[b] = c; - } - - // --- Beat / samplePeak detection --- - // Detect a sudden rise in sub-bass energy (bands 0 and 1: 63 + 160 Hz). - float lowBandSum = compressedEnv[0] + compressedEnv[1]; - float rise = lowBandSum - prevLowBandSum; - if (rise > MSGEQ7_PEAK_RISE_THRESHOLD * 255.0f && lowBandSum > p->squelch) { - s_samplePeak = 1; - peakHoldTimer = 40.0f; // hold for ~40 loop iterations (≈ 1 LED frame at 25fps) - } - prevLowBandSum = lowBandSum; - if (peakHoldTimer > 0.0f) { - peakHoldTimer -= 1.0f; - if (peakHoldTimer <= 0.0f) s_samplePeak = 0; - } - - // --- FFT_MajorPeak via parabolic interpolation of top-2 bands --- - // Find the band with the highest compressed amplitude. - int peakBand = 0; - for (int b = 1; b < NUM_BANDS; b++) { - if (compressedEnv[b] > compressedEnv[peakBand]) peakBand = b; - } - float majorPeak = MSGEQ7_FREQS_HZ[peakBand]; - // Parabolic interpolation using log-spaced frequencies smooths the - // staircase artifact seen in FREQMAP/WATERFALL effects. - if (peakBand > 0 && peakBand < NUM_BANDS - 1) { - float a = compressedEnv[peakBand - 1]; - float b_val = compressedEnv[peakBand]; - float c = compressedEnv[peakBand + 1]; - float denom = a - 2.0f * b_val + c; - if (fabsf(denom) > 1e-6f) { - // Fractional offset in log-frequency space, range [-0.5, +0.5] - float offset = 0.5f * (a - c) / denom; - float logFreqLo = log10f(MSGEQ7_FREQS_HZ[peakBand > 0 ? peakBand - 1 : 0]); - float logFreqHi = log10f(MSGEQ7_FREQS_HZ[peakBand < NUM_BANDS-1 ? peakBand + 1 : NUM_BANDS-1]); - float logFreqPeak = log10f(MSGEQ7_FREQS_HZ[peakBand]); - float step = (offset >= 0.0f) ? (logFreqHi - logFreqPeak) : (logFreqPeak - logFreqLo); - majorPeak = powf(10.0f, logFreqPeak + offset * step); - } - } - // Clamp to the range expected by effects (1 Hz .. Nyquist). - if (majorPeak < 1.0f) majorPeak = 1.0f; - if (majorPeak > MSGEQ7_SAMPLE_RATE / 2) majorPeak = MSGEQ7_SAMPLE_RATE / 2; - - // --- my_magnitude: scaled peak band amplitude --- - float magnitude = compressedEnv[peakBand] * p->gainLevel; - if (magnitude < 0.001f) magnitude = 0.001f; - - // --- Volume from RMS and block peak --- - // volumeSmth: smoothed RMS-based volume, scaled to 0..255 range. - // Normalise by a typical maximum value then apply log compression. - float normRMS = blockRMS / 32768.0f * p->gainLevel; // float samples are ~[-32768..32768] - float volSmth = 255.0f * (log10f(1.0f + MSGEQ7_LOG_SCALE * constrain(normRMS, 0.0f, 1.0f)) - / log10f(1.0f + MSGEQ7_LOG_SCALE)); - uint16_t volRaw = (uint16_t)constrain(blockPeak * p->gainLevel / 128.0f, 0.0f, 65535.0f); - - // --- Debug output for sweep_analyze.py (compiled out unless SR_DEBUG) --- -#ifdef SR_DEBUG - DEBUGSR_PRINTF("MSGEQ7 bands: 0=%d 1=%d 2=%d 3=%d 4=%d 5=%d 6=%d\n", - (int)compressedEnv[0], (int)compressedEnv[1], (int)compressedEnv[2], - (int)compressedEnv[3], (int)compressedEnv[4], (int)compressedEnv[5], - (int)compressedEnv[6]); -#endif - - // --- Write to shared volatile state --- - for (int b = 0; b < NUM_BANDS; b++) s_bandEnvelope[b] = compressedEnv[b]; - s_volumeSmth = volSmth; - s_volumeRaw = volRaw; - s_FFT_MajorPeak = majorPeak; - s_my_magnitude = magnitude; - // s_samplePeak is written above - } // while (!p->stop) - - free(inputBuf); - free(filteredBuf); - // Signal _stopProcessing() that we have fully exited before the task is deleted. - s_swTaskHandle = nullptr; - vTaskDelete(nullptr); -} -// AI: end - -// ─── MSGEQ7 Usermod class ───────────────────────────────────────────────────── +// ─── MSGEQ7 Usermod ─────────────────────────────────────────────────────────── class MSGEQ7Usermod : public Usermod { @@ -420,21 +55,21 @@ class MSGEQ7Usermod : public Usermod { void setup() override { if (_initDone) return; - // Allocate the um_data structure. Layout mirrors audioreactive exactly so - // existing effects work without any modification. + // Allocate um_data matching audioreactive's exact 8-slot layout so all + // audio-reactive effects work without modification. _umData = new um_data_t; _umData->u_size = 8; _umData->u_type = new um_types_t[_umData->u_size]; _umData->u_data = new void*[_umData->u_size]; - _umData->u_data[0] = &_volumeSmth; _umData->u_type[0] = UMT_FLOAT; - _umData->u_data[1] = &_volumeRaw; _umData->u_type[1] = UMT_UINT16; - _umData->u_data[2] = _fftResult; _umData->u_type[2] = UMT_BYTE_ARR; - _umData->u_data[3] = &_samplePeak; _umData->u_type[3] = UMT_BYTE; - _umData->u_data[4] = &_FFT_MajorPeak; _umData->u_type[4] = UMT_FLOAT; - _umData->u_data[5] = &_my_magnitude; _umData->u_type[5] = UMT_FLOAT; - _umData->u_data[6] = &s_maxVol; _umData->u_type[6] = UMT_BYTE; - _umData->u_data[7] = &s_binNum; _umData->u_type[7] = UMT_BYTE; + _umData->u_data[0] = &_volumeSmth; _umData->u_type[0] = UMT_FLOAT; + _umData->u_data[1] = &_volumeRaw; _umData->u_type[1] = UMT_UINT16; + _umData->u_data[2] = _fftResult; _umData->u_type[2] = UMT_BYTE_ARR; + _umData->u_data[3] = &_samplePeak; _umData->u_type[3] = UMT_BYTE; + _umData->u_data[4] = &_FFT_MajorPeak; _umData->u_type[4] = UMT_FLOAT; + _umData->u_data[5] = &_my_magnitude; _umData->u_type[5] = UMT_FLOAT; + _umData->u_data[6] = &s_maxVol; _umData->u_type[6] = UMT_BYTE; + _umData->u_data[7] = &s_binNum; _umData->u_type[7] = UMT_BYTE; buildInterpolationTable(); _initDone = true; @@ -445,7 +80,7 @@ class MSGEQ7Usermod : public Usermod { void loop() override { if (!_enabled) return; - // Guard: be nice to LED refresh, same pattern as audioreactive + // Guard: be nice to LED refresh, same pattern as audioreactive. if (strip.isUpdating() && (millis() - _lastLoopMs < 2)) return; _lastLoopMs = millis(); @@ -453,8 +88,8 @@ class MSGEQ7Usermod : public Usermod { _readHardwareChip(); } - // Copy shared volatile state into the um_data member variables. - // This is the only place where the volatile→non-volatile copy happens, + // Copy shared volatile output state into the um_data member variables. + // This is the only place where the volatile → non-volatile copy happens, // keeping the effect read path simple. _volumeSmth = s_volumeSmth; _volumeRaw = s_volumeRaw; @@ -462,14 +97,11 @@ class MSGEQ7Usermod : public Usermod { _my_magnitude = s_my_magnitude; _samplePeak = s_samplePeak; - // Build 16-channel fftResult[] from 7 MSGEQ7 band envelopes via precomputed - // log-frequency interpolation table. + // Expand 7 MSGEQ7 bands to 16 GEQ channels via the precomputed + // log-frequency interpolation table (built in buildInterpolationTable). for (int ch = 0; ch < NUM_GEQ_CHANNELS; ch++) { const GEQInterp &g = s_geqInterp[ch]; - float lo = s_bandEnvelope[g.lo]; - float hi = s_bandEnvelope[g.hi]; - float val = lo + g.t * (hi - lo); - // Clamp and round to uint8_t (0..255). + float val = s_bandEnvelope[g.lo] + g.t * (s_bandEnvelope[g.hi] - s_bandEnvelope[g.lo]); if (val < 0.0f) val = 0.0f; if (val > 255.0f) val = 255.0f; _fftResult[ch] = (uint8_t)val; @@ -516,47 +148,44 @@ class MSGEQ7Usermod : public Usermod { JsonObject top = root[FPSTR(_name)]; if (top.isNull()) return false; - bool changed = false; - bool newEnabled = top[F("enabled")] | _enabled; - bool newUseHwChip = top[F("useHwChip")] | _useHardwareChip; - uint8_t newDmType = top[F("dmType")] | _dmType; - int8_t newPinSD = top[F("pinSD")] | _pinSD; - int8_t newPinWS = top[F("pinWS")] | _pinWS; - int8_t newPinSCK = top[F("pinSCK")] | _pinSCK; - int8_t newPinMCLK = top[F("pinMCLK")] | _pinMCLK; - int8_t newStrobe = top[F("pinStrobe")] | _pinStrobe; - int8_t newReset = top[F("pinReset")] | _pinReset; - int8_t newOut = top[F("pinOut")] | _pinOut; - uint8_t newGain = top[F("gain")] | _gainPercent; - uint8_t newSqlch = top[F("squelch")] | _squelchLevel; - float newQ = top[F("filterQ")] | _filterQ; - uint16_t newAtk = top[F("attackMs")] | _attackMs; - uint16_t newDec = top[F("decayMs")] | _decayMs; - - // Any hardware/filter change needs a full reinit. - if (newEnabled != _enabled || newUseHwChip != _useHardwareChip - || newDmType != _dmType || newPinSD != _pinSD || newPinWS != _pinWS - || newPinSCK != _pinSCK || newPinMCLK != _pinMCLK - || newStrobe != _pinStrobe || newReset != _pinReset || newOut != _pinOut - || newQ != _filterQ) { - changed = true; - } - - _enabled = newEnabled; - _useHardwareChip = newUseHwChip; - _dmType = newDmType; - _pinSD = newPinSD; - _pinWS = newPinWS; - _pinSCK = newPinSCK; - _pinMCLK = newPinMCLK; - _pinStrobe = newStrobe; - _pinReset = newReset; - _pinOut = newOut; - _gainPercent = newGain; - _squelchLevel = newSqlch; - _filterQ = newQ; - _attackMs = newAtk; - _decayMs = newDec; + bool newEnabled = top[F("enabled")] | _enabled; + bool newUseHwChip = top[F("useHwChip")] | _useHardwareChip; + uint8_t newDmType = top[F("dmType")] | _dmType; + int8_t newPinSD = top[F("pinSD")] | _pinSD; + int8_t newPinWS = top[F("pinWS")] | _pinWS; + int8_t newPinSCK = top[F("pinSCK")] | _pinSCK; + int8_t newPinMCLK = top[F("pinMCLK")] | _pinMCLK; + int8_t newStrobe = top[F("pinStrobe")] | _pinStrobe; + int8_t newReset = top[F("pinReset")] | _pinReset; + int8_t newOut = top[F("pinOut")] | _pinOut; + uint8_t newGain = top[F("gain")] | _gainPercent; + uint8_t newSqlch = top[F("squelch")] | _squelchLevel; + float newQ = top[F("filterQ")] | _filterQ; + uint16_t newAtk = top[F("attackMs")] | _attackMs; + uint16_t newDec = top[F("decayMs")] | _decayMs; + + // Any hardware or filter change requires a full reinitialisation. + bool changed = (newEnabled != _enabled || newUseHwChip != _useHardwareChip + || newDmType != _dmType || newPinSD != _pinSD || newPinWS != _pinWS + || newPinSCK != _pinSCK || newPinMCLK != _pinMCLK + || newStrobe != _pinStrobe || newReset != _pinReset || newOut != _pinOut + || newQ != _filterQ); + + _enabled = newEnabled; + _useHardwareChip = newUseHwChip; + _dmType = newDmType; + _pinSD = newPinSD; + _pinWS = newPinWS; + _pinSCK = newPinSCK; + _pinMCLK = newPinMCLK; + _pinStrobe = newStrobe; + _pinReset = newReset; + _pinOut = newOut; + _gainPercent = newGain; + _squelchLevel = newSqlch; + _filterQ = newQ; + _attackMs = newAtk; + _decayMs = newDec; if (changed && _initDone) { _stopProcessing(); @@ -567,7 +196,6 @@ class MSGEQ7Usermod : public Usermod { } void appendConfigData() override { - // Append UI helpers via the settings page JS API. // Labels and select options for dmType, matching audioreactive's mic types. oappend(SET_F("addInfo('MSGEQ7:dmType',1,'(software backend)');")); oappend(SET_F("dd=addDropdown('MSGEQ7','dmType');")); @@ -586,30 +214,22 @@ class MSGEQ7Usermod : public Usermod { JsonObject user = root[F("u")]; if (user.isNull()) user = root.createNestedObject(F("u")); JsonArray arr = user.createNestedArray(F("MSGEQ7")); - if (!_enabled) { - arr.add(F("disabled")); - return; - } - const char *backend = _useHardwareChip ? "hw chip" : "sw emulation"; - arr.add(backend); + if (!_enabled) { arr.add(F("disabled")); return; } + arr.add(_useHardwareChip ? F("hw chip") : F("sw emulation")); } private: - // ── Private methods ──────────────────────────────────────────────────────── + // ── Backend lifecycle ────────────────────────────────────────────────────── void _startProcessing() { - if (_useHardwareChip) { - _initHardwareChip(); - } else { - _initSoftwareBackend(); - } + if (_useHardwareChip) _initHardwareChip(); + else _initSoftwareBackend(); } void _stopProcessing() { if (!_useHardwareChip && s_swTaskHandle) { - // Signal the task to stop and wait for it to exit. + // Signal the task to stop and wait up to 500 ms for graceful exit. if (_swTaskParams) _swTaskParams->stop = true; - // Give the task up to 500ms to terminate gracefully. uint32_t deadline = millis() + 500; while (s_swTaskHandle != nullptr && millis() < deadline) delay(10); s_swTaskHandle = nullptr; @@ -625,7 +245,7 @@ class MSGEQ7Usermod : public Usermod { } _deinitHardwareChip(); - // Zero out shared state so effects don't see stale data after disable. + // Zero shared state so effects don't see stale data after disable. for (int b = 0; b < NUM_BANDS; b++) s_bandEnvelope[b] = 0.0f; s_volumeSmth = 0.0f; s_volumeRaw = 0; @@ -634,7 +254,12 @@ class MSGEQ7Usermod : public Usermod { s_samplePeak = 0; } - // ── Software backend initialisation ────────────────────────────────────── + // ── Software backend: AudioSource creation + task spawn ─────────────────── + // + // Creates the correct AudioSource subclass for the configured microphone + // type (I2S, PDM, ES7243, SPH0645, ES8388, ADC), initialises the biquad + // filter coefficients, and spawns the softwareProcessingTask from + // msgeq7_engine.h. void _initSoftwareBackend() { // Reset I2S peripheral (follows audioreactive pattern). @@ -645,7 +270,7 @@ class MSGEQ7Usermod : public Usermod { #endif delay(100); - // Infer PDM from missing SCK pin on dmType 1 and 4 (same heuristic as AR). + // Infer PDM from missing SCK on generic I2S types (matches audioreactive). #if !defined(CONFIG_IDF_TARGET_ESP32S2) && !defined(CONFIG_IDF_TARGET_ESP32C3) if ((_pinSCK == I2S_PIN_NO_CHANGE) && (_pinSD >= 0) && (_pinWS >= 0) && ((_dmType == 1) || (_dmType == 4))) { @@ -655,9 +280,9 @@ class MSGEQ7Usermod : public Usermod { switch (_dmType) { #if defined(CONFIG_IDF_TARGET_ESP32S2) || defined(CONFIG_IDF_TARGET_ESP32C3) || defined(CONFIG_IDF_TARGET_ESP32S3) - case 0: // ADC — not supported on S2/C3/S3 + case 0: // ADC not supported on S2/C3/S3 #if defined(CONFIG_IDF_TARGET_ESP32S2) || defined(CONFIG_IDF_TARGET_ESP32C3) - case 5: // PDM — not supported on S2/C3 + case 5: // PDM not supported on S2/C3 #endif #endif case 1: @@ -714,11 +339,11 @@ class MSGEQ7Usermod : public Usermod { // Compute biquad coefficients for all 7 bands. initBiquadCoeffs((float)MSGEQ7_SAMPLE_RATE, _filterQ); - // Compute envelope coefficients from ms time constants and the block rate. - // The envelope is updated once per sample (not per block), so use sample rate. - float attackC = timeConstToCoeff(_attackMs * 0.001f, (float)MSGEQ7_SAMPLE_RATE); - float decayC = timeConstToCoeff(_decayMs * 0.001f, (float)MSGEQ7_SAMPLE_RATE); - float linearGain = (_gainPercent / 128.0f); // 128 = unity gain + // Compute envelope time-constant coefficients from ms settings. + // Envelope is updated per sample, so use the sample rate here. + float attackC = timeConstToCoeff(_attackMs * 0.001f, (float)MSGEQ7_SAMPLE_RATE); + float decayC = timeConstToCoeff(_decayMs * 0.001f, (float)MSGEQ7_SAMPLE_RATE); + float linearGain = _gainPercent / 128.0f; _swTaskParams = new SWTaskParams{ /*.source =*/ _audioSource, @@ -746,6 +371,9 @@ class MSGEQ7Usermod : public Usermod { } // ── Hardware MSGEQ7 chip backend ────────────────────────────────────────── + // + // PinManager is WLED-specific, so pin allocation/deallocation lives here. + // The GPIO protocol and ADC read loop live in msgeq7_engine.h. void _initHardwareChip() { if (_pinStrobe < 0 || _pinReset < 0 || _pinOut < 0) { @@ -759,11 +387,8 @@ class MSGEQ7Usermod : public Usermod { _deinitHardwareChip(); return; } - pinMode(_pinStrobe, OUTPUT); - pinMode(_pinReset, OUTPUT); - // OUT is analog input — configure via analogRead (no pinMode needed) - digitalWrite(_pinStrobe, HIGH); - digitalWrite(_pinReset, LOW); + msgeq7_hw_gpio_init(_pinStrobe, _pinReset); + // OUT is an analog input; no pinMode needed — analogRead() configures it. _hwChipReady = true; } @@ -776,92 +401,15 @@ class MSGEQ7Usermod : public Usermod { } } - // Read all 7 bands from the physical MSGEQ7 chip. - // The chip outputs band amplitudes one at a time as an analog voltage. - // Protocol (from MSGEQ7 datasheet): - // 1. Pulse RESET high for ≥100ns to reset the internal mux to band 0. - // 2. For each band: pull STROBE low, wait ≥36 µs, read OUT, pull STROBE high. - // 3. Repeat step 2 for all 7 bands (the mux auto-advances on each strobe). void _readHardwareChip() { if (!_hwChipReady) return; - uint32_t now = millis(); - if (now - _lastHwReadMs < MSGEQ7_HW_READ_INTERVAL_MS) return; - _lastHwReadMs = now; - - // Reset internal mux to band 0. - digitalWrite(_pinReset, HIGH); - delayMicroseconds(1); - digitalWrite(_pinReset, LOW); - delayMicroseconds(72); // allow output to settle after reset - - float linearGain = (_gainPercent / 128.0f); - float squelch = (float)_squelchLevel; - float prevLow = s_bandEnvelope[0] + s_bandEnvelope[1]; - - for (int b = 0; b < NUM_BANDS; b++) { - digitalWrite(_pinStrobe, LOW); - delayMicroseconds(36); // strobe-to-output delay (Tso) - uint16_t adcVal = analogRead(_pinOut); // 12-bit ADC: 0..4095 - digitalWrite(_pinStrobe, HIGH); - delayMicroseconds(36); // hold before next strobe (Tsh ≥ 18µs) - - // Scale 12-bit ADC (0..4095) to 0..255 using the full ADC resolution - // before truncating, then apply user gain. Avoids the 4-bit precision - // loss of `>>4` — small differences near the noise floor stay visible. - float val = (adcVal * (255.0f / 4095.0f)) * linearGain; - if (val < squelch) val = 0.0f; - if (val > 255.0f) val = 255.0f; - s_bandEnvelope[b] = val; - } - - // Hardware chip output is already log-compressed (analog voltage from chip). - // Compute derived values from the band data. - float envSum = 0.0f; - float envPeak = 0.0f; - int peakBand = 0; - for (int b = 0; b < NUM_BANDS; b++) { - float v = s_bandEnvelope[b]; - envSum += v; - if (v > envPeak) { envPeak = v; peakBand = b; } - } - - // volumeSmth: weighted sum of all bands, normalised. - float volSmth = envSum / (float)NUM_BANDS; - s_volumeSmth = volSmth; - s_volumeRaw = (uint16_t)constrain(envPeak, 0.0f, 65535.0f); - s_my_magnitude = envPeak > 0.001f ? envPeak : 0.001f; - - // FFT_MajorPeak with parabolic interpolation (same as software path). - float majorPeak = MSGEQ7_FREQS_HZ[peakBand]; - if (peakBand > 0 && peakBand < NUM_BANDS - 1) { - float a = s_bandEnvelope[peakBand - 1]; - float b_v = s_bandEnvelope[peakBand]; - float c = s_bandEnvelope[peakBand + 1]; - float denom = a - 2.0f * b_v + c; - if (fabsf(denom) > 1e-6f) { - float offset = 0.5f * (a - c) / denom; - float logLo = log10f(MSGEQ7_FREQS_HZ[peakBand > 0 ? peakBand-1 : 0]); - float logHi = log10f(MSGEQ7_FREQS_HZ[peakBand < NUM_BANDS-1 ? peakBand+1 : NUM_BANDS-1]); - float logPk = log10f(MSGEQ7_FREQS_HZ[peakBand]); - float step = (offset >= 0.0f) ? (logHi - logPk) : (logPk - logLo); - majorPeak = powf(10.0f, logPk + offset * step); - } - } - if (majorPeak < 1.0f) majorPeak = 1.0f; - s_FFT_MajorPeak = majorPeak; - - // Beat detection on low bands. - float lowSum = s_bandEnvelope[0] + s_bandEnvelope[1]; - if ((lowSum - prevLow) > MSGEQ7_PEAK_RISE_THRESHOLD * 255.0f && lowSum > squelch) { - s_samplePeak = 1; - } else { - s_samplePeak = 0; - } + msgeq7_hw_read(_pinStrobe, _pinReset, _pinOut, + _gainPercent, _squelchLevel, _lastHwReadMs); } // ── Member variables ─────────────────────────────────────────────────────── - // um_data output variables (these are the actual values effects read via pointers) + // um_data output values (effects read these via pointers registered in setup) float _volumeSmth = 0.0f; uint16_t _volumeRaw = 0; uint8_t _fftResult[NUM_GEQ_CHANNELS] = {}; @@ -869,16 +417,12 @@ class MSGEQ7Usermod : public Usermod { float _FFT_MajorPeak = 1.0f; float _my_magnitude = 0.001f; - // um_data container - um_data_t *_umData = nullptr; - - // Audio source (software backend only) - AudioSource *_audioSource = nullptr; - - // FreeRTOS task params (software backend) + // WLED data structures + um_data_t *_umData = nullptr; + AudioSource *_audioSource = nullptr; SWTaskParams *_swTaskParams = nullptr; - // Settings + // Settings (persisted to JSON config) bool _enabled = false; bool _useHardwareChip = false; uint8_t _dmType = 1; // 1 = Generic I2S (default) @@ -895,11 +439,11 @@ class MSGEQ7Usermod : public Usermod { uint16_t _attackMs = 15; uint16_t _decayMs = 80; - // State flags - bool _initDone = false; - bool _hwChipReady = false; - uint32_t _lastHwReadMs = 0; - uint32_t _lastLoopMs = 0; + // Runtime state + bool _initDone = false; + bool _hwChipReady = false; + uint32_t _lastHwReadMs = 0; + uint32_t _lastLoopMs = 0; // PROGMEM key for config JSON static const char _name[]; diff --git a/usermods/msgeq7/msgeq7_engine.h b/usermods/msgeq7/msgeq7_engine.h new file mode 100644 index 0000000000..4d3af43529 --- /dev/null +++ b/usermods/msgeq7/msgeq7_engine.h @@ -0,0 +1,465 @@ +/* + * msgeq7_engine.h — MSGEQ7 signal processing and hardware protocol + * + * All code here is independent of the WLED usermod API and can be transplanted + * into a different host (e.g. the audioreactive usermod) by including this + * header after wled.h and audio_source.h. + * + * Contents: + * - Physical constants for the 7 MSGEQ7 bands + * - Shared volatile output state (written by the engine, read by the usermod) + * - 7-band → 16-channel GEQ interpolation table (buildInterpolationTable) + * - Biquad bandpass filter bank + peak-hold envelope detector + * - FreeRTOS software processing task (I2S → filter → compress → shared state) + * - Physical MSGEQ7 chip GPIO protocol (msgeq7_hw_gpio_init, msgeq7_hw_read) + * + * Prerequisites: include wled.h and audio_source.h before this header. + * + * AI: below section was generated by an AI + */ + +#pragma once + +// AudioSource is defined in audio_source.h — include that before this header. +class AudioSource; + +#include +#include + +// ─── Constants ─────────────────────────────────────────────────────────────── + +// Software backend sample rate. Must be ≥ 32000 Hz to represent the 16 kHz band. +// 44100 Hz is the standard CD audio rate and gives a comfortable margin above 16 kHz. +static constexpr uint32_t MSGEQ7_SAMPLE_RATE = 44100; + +// I2S DMA block size (samples per read). Chosen to match audioreactive. +static constexpr int MSGEQ7_BLOCK_SIZE = 128; + +// Number of MSGEQ7 frequency bands. +static constexpr int NUM_BANDS = 7; + +// Number of GEQ output channels expected by WLED effects. +static constexpr int NUM_GEQ_CHANNELS = 16; + +// Center frequencies of the 7 MSGEQ7 bandpass filters (Hz). +// Source: MSGEQ7 datasheet (Mixed Signal Integration MSI, Inc.). +static constexpr float MSGEQ7_FREQS_HZ[NUM_BANDS] = { + 63.0f, 160.0f, 400.0f, 1000.0f, 2500.0f, 6250.0f, 16000.0f +}; + +// Default filter Q (quality factor). Real MSGEQ7 bands are spaced by a factor +// of 2.5 (≈1.32 octaves) and overlap noticeably; the corresponding Q for +// non-overlapping bandpasses at this spacing is Q = sqrt(2^N)/(2^N − 1) ≈ 1.05 +// with N = 1.32 octaves. Q = 1.0 closely matches the chip's response — slightly +// wider than 1.05 to give modest band overlap as the real chip exhibits. +static constexpr float MSGEQ7_DEFAULT_Q = 1.0f; + +// Envelope detector time constants (seconds). +// Approximate real MSGEQ7 peak-hold behaviour: fast attack, slow decay. +static constexpr float MSGEQ7_ATTACK_SEC = 0.015f; // 15 ms attack +static constexpr float MSGEQ7_DECAY_SEC = 0.080f; // 80 ms decay + +// Log compression: output = 255 * log10(1 + LOG_SCALE * x) / log10(1 + LOG_SCALE) +// LOG_SCALE = 9 maps linear 0..1 → compressed 0..1 with more resolution at low +// levels, matching the real chip's logarithmic output characteristic. +static constexpr float MSGEQ7_LOG_SCALE = 9.0f; + +// FreeRTOS task settings for the software processing task. +static constexpr uint8_t MSGEQ7_TASK_PRIORITY = 1; +static constexpr uint32_t MSGEQ7_TASK_STACK = 3072; // bytes + +// Minimum ms between hardware MSGEQ7 chip reads (~50 Hz update rate). +static constexpr uint32_t MSGEQ7_HW_READ_INTERVAL_MS = 20; + +// Beat detection: trigger samplePeak when sub-bass energy rises faster than +// this fraction of full scale per update interval. +static constexpr float MSGEQ7_PEAK_RISE_THRESHOLD = 0.15f; + +// ─── Shared output state ───────────────────────────────────────────────────── +// +// Written exclusively by softwareProcessingTask (SW path) or msgeq7_hw_read +// (HW path). Read exclusively by the usermod's loop(). +// Single-writer / single-reader → volatile is sufficient; no mutex needed. + +static volatile float s_bandEnvelope[NUM_BANDS] = {}; // 0.0–255.0 per band +static volatile float s_volumeSmth = 0.0f; +static volatile uint16_t s_volumeRaw = 0; +static volatile float s_FFT_MajorPeak = 1.0f; +static volatile float s_my_magnitude = 0.001f; +static volatile uint8_t s_samplePeak = 0; + +// Writable by effects via um_data pointers (matches audioreactive layout). +static uint8_t s_maxVol = 31; +static uint8_t s_binNum = 8; + +// ─── GEQ interpolation table: 7 bands → 16 channels ───────────────────────── +// +// Each of the 16 GEQ output channels is assigned a representative frequency +// derived from audioreactive's FFT bin groupings at 22050 Hz. We locate the +// two bracketing MSGEQ7 bands and store a linear interpolation weight in +// log-frequency space. Built once in setup(); applied each loop(). + +struct GEQInterp { + uint8_t lo; // lower MSGEQ7 band index + uint8_t hi; // upper MSGEQ7 band index (lo + 1, clamped to NUM_BANDS - 1) + float t; // blend weight towards hi: output = lo_val*(1-t) + hi_val*t +}; + +static GEQInterp s_geqInterp[NUM_GEQ_CHANNELS]; + +// Representative center frequencies for the 16 GEQ output channels (Hz). +// Derived from audioreactive's fftAddAvg() bin ranges at 22050 Hz / 512 bins +// (43.07 Hz per bin); center = (from + to) / 2 * 43.07. +static constexpr float GEQ_TARGET_FREQS_HZ[NUM_GEQ_CHANNELS] = { + 64.6f, 107.7f, 172.3f, 258.4f, + 344.5f, 472.8f, 667.9f, 926.0f, + 1291.0f, 1829.7f, 2561.6f, 3638.3f, + 5229.9f, 7468.5f, 9117.0f, 12903.0f +}; + +// Build s_geqInterp[] from GEQ_TARGET_FREQS_HZ and MSGEQ7_FREQS_HZ. +// Uses log10 scale so perceptually equal frequency spacings map linearly. +static void buildInterpolationTable(void) { + float logBands[NUM_BANDS]; + for (int b = 0; b < NUM_BANDS; b++) logBands[b] = log10f(MSGEQ7_FREQS_HZ[b]); + + for (int ch = 0; ch < NUM_GEQ_CHANNELS; ch++) { + float logTarget = log10f(GEQ_TARGET_FREQS_HZ[ch]); + int lo = 0; + for (int b = 0; b < NUM_BANDS - 1; b++) { + if (logBands[b] <= logTarget) lo = b; + } + int hi = (lo + 1 < NUM_BANDS) ? lo + 1 : NUM_BANDS - 1; + float t = 0.0f; + if (hi != lo) { + t = (logTarget - logBands[lo]) / (logBands[hi] - logBands[lo]); + if (t < 0.0f) t = 0.0f; + if (t > 1.0f) t = 1.0f; + } + s_geqInterp[ch] = { (uint8_t)lo, (uint8_t)hi, t }; + } +} + +// ─── Biquad bandpass filter bank ───────────────────────────────────────────── +// +// One second-order IIR bandpass filter per MSGEQ7 band, implemented with the +// esp-dsp biquad routines. The SIMD-accelerated variants (ae32 / aes3) are +// selected at compile time where available. + +// esp-dsp biquad requires 16-byte-aligned coefficient and delay-line arrays. +struct alignas(16) BandFilter { + float coeffs[5]; // {b0, b1, b2, a1, a2} from dsps_biquad_gen_bpf_f32() + float w[2]; // IIR delay line state, zeroed on init / reinit +}; + +static BandFilter s_filters[NUM_BANDS]; + +// Compute biquad coefficients for all 7 bands at the given sample rate and Q. +// Zeroes the delay lines. Call once at startup and whenever Q changes. +static void initBiquadCoeffs(float sampleRate, float Q) { + for (int b = 0; b < NUM_BANDS; b++) { + // Normalised frequency for esp-dsp: f = fc / fs, range [0 .. 0.5). + // The 16 kHz band at 44100 Hz gives f ≈ 0.363 — well within range. + float f = MSGEQ7_FREQS_HZ[b] / sampleRate; + if (f >= 0.5f) f = 0.499f; // hard clamp below Nyquist + dsps_biquad_gen_bpf_f32(s_filters[b].coeffs, f, Q); + s_filters[b].w[0] = 0.0f; + s_filters[b].w[1] = 0.0f; + } +} + +// Convert a time constant (seconds) to a one-pole IIR coefficient. +// coeff = 1 − exp(−1 / (tau * fs)); near 0 = slow, near 1 = fast. +static float timeConstToCoeff(float timeConst_s, float sampleRate) { + return 1.0f - expf(-1.0f / (timeConst_s * sampleRate)); +} + +// ─── Software processing task ──────────────────────────────────────────────── +// +// Pinned to Core 0 (same core as audioreactive's FFT task). +// Reads I2S audio blocks, runs all 7 biquad bandpass filters, applies a +// peak-hold envelope detector, log-compresses the result to 0..255, then +// writes into the shared volatile state variables above. + +struct SWTaskParams { + AudioSource *source; // I2S/ADC audio source (created by the usermod) + float Q; // filter Q (stored for reference; coeffs pre-built) + float gainLevel; // linear input gain (1.0 = unity) + float squelch; // noise-gate threshold on the 0..255 output scale + float attackCoeff; // one-pole coefficient for envelope attack + float decayCoeff; // one-pole coefficient for envelope decay + volatile bool stop; // set true from the main task to request exit +}; + +static volatile TaskHandle_t s_swTaskHandle = nullptr; + +static void IRAM_ATTR softwareProcessingTask(void *pvParams) { + SWTaskParams *p = static_cast(pvParams); + + // Heap buffers — the 3 KB task stack is too small for two 128-float arrays. + float *inputBuf = static_cast(malloc(MSGEQ7_BLOCK_SIZE * sizeof(float))); + float *filteredBuf = static_cast(malloc(MSGEQ7_BLOCK_SIZE * sizeof(float))); + if (!inputBuf || !filteredBuf) { + free(inputBuf); + free(filteredBuf); + vTaskDelete(nullptr); + return; + } + + float envelope[NUM_BANDS] = {}; + float prevLowBandSum = 0.0f; + float peakHoldTimer = 0.0f; + + while (!p->stop) { + delay(1); // feeds IDLE(0) watchdog — do not remove; see audioreactive + + if (!p->source || !p->source->isInitialized()) { + vTaskDelay(pdMS_TO_TICKS(1)); + continue; + } + + // --- Fetch one block of audio samples from the I2S DMA --- + p->source->getSamples(inputBuf, MSGEQ7_BLOCK_SIZE); + + // --- Apply input gain; compute block RMS and peak for volume output --- + float blockPeak = 0.0f; + float blockSumSq = 0.0f; + for (int i = 0; i < MSGEQ7_BLOCK_SIZE; i++) { + inputBuf[i] *= p->gainLevel; + float absVal = fabsf(inputBuf[i]); + if (absVal > blockPeak) blockPeak = absVal; + blockSumSq += absVal * absVal; + } + float blockRMS = sqrtf(blockSumSq / MSGEQ7_BLOCK_SIZE); + + // --- Run each biquad bandpass filter; update peak-hold envelope --- + for (int b = 0; b < NUM_BANDS; b++) { + // Select the best SIMD variant available at compile time. +#if dsps_biquad_f32_aes3_enabled + dsps_biquad_f32_aes3(inputBuf, filteredBuf, MSGEQ7_BLOCK_SIZE, + s_filters[b].coeffs, s_filters[b].w); +#elif dsps_biquad_f32_ae32_enabled + dsps_biquad_f32_ae32(inputBuf, filteredBuf, MSGEQ7_BLOCK_SIZE, + s_filters[b].coeffs, s_filters[b].w); +#else + dsps_biquad_f32_ansi(inputBuf, filteredBuf, MSGEQ7_BLOCK_SIZE, + s_filters[b].coeffs, s_filters[b].w); +#endif + + // Peak-hold envelope: attack on rising signal, decay on falling. + // Squelch is applied after compression on the 0..255 scale, not here. + float attackC = p->attackCoeff; + float decayC = p->decayCoeff; + float env = envelope[b]; + for (int i = 0; i < MSGEQ7_BLOCK_SIZE; i++) { + float absVal = fabsf(filteredBuf[i]); + env += (absVal > env) ? (absVal - env) * attackC + : (absVal - env) * decayC; + } + envelope[b] = env; + } + + // --- Log-compress envelopes to 0..255 ABSOLUTE scale --- + // + // The real MSGEQ7 chip outputs a voltage proportional to band amplitude — + // quiet → small, loud → large. We normalise against the full int16 sample + // range (a fixed reference, not the per-frame peak) so absolute amplitude + // is preserved. This matches audioreactive's fftResult[] semantics, which + // every WLED audio-reactive effect depends on. + // + // Curve: out = 255 * log10(1 + LOG_SCALE * (env/32768)) / log10(1 + LOG_SCALE) + static constexpr float kFullScale = 32768.0f; + const float kLogDivisor = log10f(1.0f + MSGEQ7_LOG_SCALE); + float compressedEnv[NUM_BANDS]; + for (int b = 0; b < NUM_BANDS; b++) { + float x = envelope[b] / kFullScale; + if (x < 0.0f) x = 0.0f; + if (x > 1.0f) x = 1.0f; + float c = 255.0f * log10f(1.0f + MSGEQ7_LOG_SCALE * x) / kLogDivisor; + if (c < p->squelch) c = 0.0f; // noise gate on the user-visible scale + compressedEnv[b] = c; + } + + // --- Beat / samplePeak detection --- + // Trigger on a fast rise in sub-bass energy (bands 0 + 1: 63 + 160 Hz). + float lowBandSum = compressedEnv[0] + compressedEnv[1]; + if (lowBandSum - prevLowBandSum > MSGEQ7_PEAK_RISE_THRESHOLD * 255.0f + && lowBandSum > p->squelch) { + s_samplePeak = 1; + peakHoldTimer = 40.0f; // hold ~40 iterations ≈ 1 frame at 25 fps + } + prevLowBandSum = lowBandSum; + if (peakHoldTimer > 0.0f) { + peakHoldTimer -= 1.0f; + if (peakHoldTimer <= 0.0f) s_samplePeak = 0; + } + + // --- FFT_MajorPeak: parabolic interpolation on the dominant band --- + // Smooths the staircase artifact seen in FREQMAP/WATERFALL effects. + int peakBand = 0; + for (int b = 1; b < NUM_BANDS; b++) { + if (compressedEnv[b] > compressedEnv[peakBand]) peakBand = b; + } + float majorPeak = MSGEQ7_FREQS_HZ[peakBand]; + if (peakBand > 0 && peakBand < NUM_BANDS - 1) { + float a = compressedEnv[peakBand - 1]; + float bval = compressedEnv[peakBand]; + float c = compressedEnv[peakBand + 1]; + float denom = a - 2.0f * bval + c; + if (fabsf(denom) > 1e-6f) { + // Fractional offset in log-frequency space, range [−0.5, +0.5]. + float offset = 0.5f * (a - c) / denom; + float logFreqLo = log10f(MSGEQ7_FREQS_HZ[peakBand - 1]); + float logFreqPk = log10f(MSGEQ7_FREQS_HZ[peakBand]); + float logFreqHi = log10f(MSGEQ7_FREQS_HZ[peakBand + 1]); + float step = (offset >= 0.0f) ? (logFreqHi - logFreqPk) + : (logFreqPk - logFreqLo); + majorPeak = powf(10.0f, logFreqPk + offset * step); + } + } + if (majorPeak < 1.0f) majorPeak = 1.0f; + if (majorPeak > MSGEQ7_SAMPLE_RATE / 2.0f) majorPeak = MSGEQ7_SAMPLE_RATE / 2.0f; + + // --- my_magnitude: scaled dominant-band amplitude --- + float magnitude = compressedEnv[peakBand] * p->gainLevel; + if (magnitude < 0.001f) magnitude = 0.001f; + + // --- Volume: RMS-based smooth volume and block-peak raw volume --- + float normRMS = blockRMS / 32768.0f * p->gainLevel; + float volSmth = 255.0f * log10f(1.0f + MSGEQ7_LOG_SCALE * constrain(normRMS, 0.0f, 1.0f)) + / log10f(1.0f + MSGEQ7_LOG_SCALE); + uint16_t volRaw = (uint16_t)constrain(blockPeak * p->gainLevel / 128.0f, 0.0f, 65535.0f); + +#ifdef SR_DEBUG + DEBUGSR_PRINTF("MSGEQ7 bands: 0=%d 1=%d 2=%d 3=%d 4=%d 5=%d 6=%d\n", + (int)compressedEnv[0], (int)compressedEnv[1], (int)compressedEnv[2], + (int)compressedEnv[3], (int)compressedEnv[4], (int)compressedEnv[5], + (int)compressedEnv[6]); +#endif + + // --- Publish to shared state (single writer, single reader) --- + for (int b = 0; b < NUM_BANDS; b++) s_bandEnvelope[b] = compressedEnv[b]; + s_volumeSmth = volSmth; + s_volumeRaw = volRaw; + s_FFT_MajorPeak = majorPeak; + s_my_magnitude = magnitude; + // s_samplePeak written above + } + + free(inputBuf); + free(filteredBuf); + s_swTaskHandle = nullptr; // signal _stopProcessing() that we have fully exited + vTaskDelete(nullptr); +} + +// ─── Hardware MSGEQ7 chip: GPIO protocol ───────────────────────────────────── +// +// The physical MSGEQ7 chip exposes 7 band amplitudes sequentially as an analog +// voltage on the OUT pin. A STROBE pulse advances the internal band multiplexer; +// a RESET pulse resets it back to band 0. +// +// Datasheet timings (MSI MSGEQ7): +// Tss (reset-to-strobe setup) ≥ 72 µs +// Tso (strobe-to-output valid) ≤ 36 µs +// Tsh (strobe hold high) ≥ 18 µs +// Trh (reset pulse width) ≥ 100 ns +// Trs (reset-to-first-strobe) ≥ 72 µs +// +// Reference: NicoHood MSGEQ7 library (https://github.com/NicoHood/MSGEQ7) + +// Configure output GPIO pins for the STROBE and RESET lines. +// Call after PinManager::allocatePin() has reserved the pins. +static void msgeq7_hw_gpio_init(int8_t pinStrobe, int8_t pinReset) { + pinMode(pinStrobe, OUTPUT); + pinMode(pinReset, OUTPUT); + digitalWrite(pinStrobe, HIGH); // STROBE idles high + digitalWrite(pinReset, LOW); // RESET idles low +} + +// Read all 7 band amplitudes from the physical MSGEQ7 chip. +// +// pinStrobe, pinReset, pinOut: GPIO pin numbers (pinOut must be on ADC1). +// gainPercent : user gain setting (128 = unity gain). +// squelch : noise-floor threshold on the 0..255 output scale. +// lastReadMs : [in/out] timestamp of the most recent read for rate-limiting. +// +// Writes directly to s_bandEnvelope[] and the other shared state variables. +// Returns false if the call was skipped (MSGEQ7_HW_READ_INTERVAL_MS not elapsed). +static bool msgeq7_hw_read(int8_t pinStrobe, int8_t pinReset, int8_t pinOut, + uint8_t gainPercent, uint8_t squelch, + uint32_t &lastReadMs) { + uint32_t now = millis(); + if (now - lastReadMs < MSGEQ7_HW_READ_INTERVAL_MS) return false; + lastReadMs = now; + + float linearGain = gainPercent / 128.0f; + float squelchF = (float)squelch; + float prevLow = s_bandEnvelope[0] + s_bandEnvelope[1]; + + // Pulse RESET high for ≥ 100 ns to reset the internal mux to band 0, + // then wait Trs ≥ 72 µs for the first output to settle. + digitalWrite(pinReset, HIGH); + delayMicroseconds(1); // well above the 100 ns minimum + digitalWrite(pinReset, LOW); + delayMicroseconds(72); // Trs settling time + + for (int b = 0; b < NUM_BANDS; b++) { + // Pull STROBE low; wait Tso for the output to settle; read ADC; + // return STROBE high and wait Tsh before the next band. + digitalWrite(pinStrobe, LOW); + delayMicroseconds(36); // Tso ≤ 36 µs + uint16_t adcVal = analogRead(pinOut); // 12-bit, 0..4095 + digitalWrite(pinStrobe, HIGH); + delayMicroseconds(36); // Tsh ≥ 18 µs + + // Map 12-bit ADC (0..4095) to 0..255 preserving full resolution, + // then apply user gain. Avoids the 4-bit precision loss of >>4. + float val = (adcVal * (255.0f / 4095.0f)) * linearGain; + if (val < squelchF) val = 0.0f; + if (val > 255.0f) val = 255.0f; + s_bandEnvelope[b] = val; + } + + // --- Derive volumeSmth, volumeRaw, my_magnitude from band data --- + // Hardware chip output is already log-compressed (analog voltage from chip). + float envSum = 0.0f; + float envPeak = 0.0f; + int peakBand = 0; + for (int b = 0; b < NUM_BANDS; b++) { + float v = s_bandEnvelope[b]; + envSum += v; + if (v > envPeak) { envPeak = v; peakBand = b; } + } + s_volumeSmth = envSum / (float)NUM_BANDS; + s_volumeRaw = (uint16_t)constrain(envPeak, 0.0f, 65535.0f); + s_my_magnitude = (envPeak > 0.001f) ? envPeak : 0.001f; + + // --- FFT_MajorPeak: parabolic interpolation on the dominant band --- + float majorPeak = MSGEQ7_FREQS_HZ[peakBand]; + if (peakBand > 0 && peakBand < NUM_BANDS - 1) { + float a = s_bandEnvelope[peakBand - 1]; + float bval = s_bandEnvelope[peakBand]; + float c = s_bandEnvelope[peakBand + 1]; + float denom = a - 2.0f * bval + c; + if (fabsf(denom) > 1e-6f) { + float offset = 0.5f * (a - c) / denom; + float logFreqLo = log10f(MSGEQ7_FREQS_HZ[peakBand - 1]); + float logFreqPk = log10f(MSGEQ7_FREQS_HZ[peakBand]); + float logFreqHi = log10f(MSGEQ7_FREQS_HZ[peakBand + 1]); + float step = (offset >= 0.0f) ? (logFreqHi - logFreqPk) + : (logFreqPk - logFreqLo); + majorPeak = powf(10.0f, logFreqPk + offset * step); + } + } + if (majorPeak < 1.0f) majorPeak = 1.0f; + s_FFT_MajorPeak = majorPeak; + + // --- Beat detection on low-band energy --- + float lowSum = s_bandEnvelope[0] + s_bandEnvelope[1]; + s_samplePeak = ((lowSum - prevLow) > MSGEQ7_PEAK_RISE_THRESHOLD * 255.0f + && lowSum > squelchF) ? 1 : 0; + + return true; +} + +// AI: end From 90a56fa8ad6a9360473940c0c3460e2152384151 Mon Sep 17 00:00:00 2001 From: Will Tatam Date: Sun, 7 Jun 2026 12:14:07 +0100 Subject: [PATCH 5/5] Fix msgeq7 bugs found in code review: UAF, pin leak, task handle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 1 (critical): SW task was never stopped on SW→HW mode switch. readFromConfig() updates _useHardwareChip before calling _stopProcessing(), so the old 'if (!_useHardwareChip && s_swTaskHandle)' guard always evaluated false on a mode switch, leaving the task running and causing use-after-free when params and AudioSource were then deleted. Fix: check s_swTaskHandle alone, independent of current mode. Bug 2 (critical): If the 500ms stop timeout expired, _swTaskParams and _audioSource were unconditionally deleted while the still-running task held pointers to both. Fix: on timeout, null both pointers without freeing (accepting a small leak in this pathological scenario) so the running task never dereferences freed memory. Bug 3: Partial allocatePin failure leaked already-allocated pins. _deinitHardwareChip() was called before _hwChipReady was set, making it a no-op. Fix: explicitly call deallocatePin for each pin on failure path; PinManager::deallocatePin is a safe no-op for pins not owned by us. Bug 4: On malloc failure inside softwareProcessingTask, the task called vTaskDelete without first clearing s_swTaskHandle, leaving _stopProcessing() to busy-poll a dead handle for 500ms. Fix: set s_swTaskHandle = nullptr before vTaskDelete in the malloc-failure path. Also add a prominent single-TU contract comment to msgeq7_engine.h (the static globals break silently if the header is included in >1 TU), and add a Source layout section to readme.md. --- usermods/msgeq7/msgeq7.cpp | 21 +++++++++++++++++---- usermods/msgeq7/msgeq7_engine.h | 11 +++++++++++ usermods/msgeq7/readme.md | 19 +++++++++++++++++++ 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/usermods/msgeq7/msgeq7.cpp b/usermods/msgeq7/msgeq7.cpp index 516073bdfb..167e47bcd3 100644 --- a/usermods/msgeq7/msgeq7.cpp +++ b/usermods/msgeq7/msgeq7.cpp @@ -227,12 +227,21 @@ class MSGEQ7Usermod : public Usermod { } void _stopProcessing() { - if (!_useHardwareChip && s_swTaskHandle) { - // Signal the task to stop and wait up to 500 ms for graceful exit. + // Stop the SW task regardless of the current _useHardwareChip value: + // readFromConfig() updates _useHardwareChip BEFORE calling here, so + // checking it would silently skip teardown on a SW→HW mode switch. + if (s_swTaskHandle) { if (_swTaskParams) _swTaskParams->stop = true; uint32_t deadline = millis() + 500; while (s_swTaskHandle != nullptr && millis() < deadline) delay(10); - s_swTaskHandle = nullptr; + if (s_swTaskHandle != nullptr) { + // I2S stall: task didn't exit within 500 ms. Leak params and source + // rather than freeing memory the still-running task has a pointer to. + DEBUG_PRINTLN(F("MSGEQ7: SW task did not exit in 500ms; leaking params")); + _swTaskParams = nullptr; + _audioSource = nullptr; // skip delete below + s_swTaskHandle = nullptr; + } } if (_swTaskParams) { delete _swTaskParams; @@ -384,7 +393,11 @@ class MSGEQ7Usermod : public Usermod { || !PinManager::allocatePin(_pinReset, true, PinOwner::UM_Audioreactive) || !PinManager::allocatePin(_pinOut, false, PinOwner::UM_Audioreactive)) { DEBUG_PRINTLN(F("MSGEQ7: failed to allocate hw chip pins")); - _deinitHardwareChip(); + // Explicitly deallocate each pin; deallocatePin is a no-op for any + // pin that was not successfully claimed by us, so this is always safe. + PinManager::deallocatePin(_pinStrobe, PinOwner::UM_Audioreactive); + PinManager::deallocatePin(_pinReset, PinOwner::UM_Audioreactive); + PinManager::deallocatePin(_pinOut, PinOwner::UM_Audioreactive); return; } msgeq7_hw_gpio_init(_pinStrobe, _pinReset); diff --git a/usermods/msgeq7/msgeq7_engine.h b/usermods/msgeq7/msgeq7_engine.h index 4d3af43529..cb10492dee 100644 --- a/usermods/msgeq7/msgeq7_engine.h +++ b/usermods/msgeq7/msgeq7_engine.h @@ -20,6 +20,16 @@ #pragma once +// ── IMPORTANT: single-include header ───────────────────────────────────────── +// All state variables and functions below are declared `static` (internal +// linkage). This header is designed to be included from EXACTLY ONE translation +// unit (msgeq7.cpp). Including it in a second TU would create independent +// copies of all state, silently breaking the SW task's stop mechanism and the +// band-envelope data that the usermod reads. +// If this code is transplanted into a larger host, convert the definitions to +// a proper .cpp file with `extern` declarations in the header. +// ───────────────────────────────────────────────────────────────────────────── + // AudioSource is defined in audio_source.h — include that before this header. class AudioSource; @@ -202,6 +212,7 @@ static void IRAM_ATTR softwareProcessingTask(void *pvParams) { if (!inputBuf || !filteredBuf) { free(inputBuf); free(filteredBuf); + s_swTaskHandle = nullptr; // signal _stopProcessing() we exited before looping vTaskDelete(nullptr); return; } diff --git a/usermods/msgeq7/readme.md b/usermods/msgeq7/readme.md index 29cddb9cdd..0bce77ac1f 100644 --- a/usermods/msgeq7/readme.md +++ b/usermods/msgeq7/readme.md @@ -162,6 +162,25 @@ sequence (band 0 first, band 6 last) for a low-to-high frequency sweep. --- +## Source layout + +``` +usermods/msgeq7/ + msgeq7.cpp — WLED usermod glue: AudioSource init, PinManager, + um_data registration, JSON config, registration + msgeq7_engine.h — Self-contained DSP engine: constants, shared state, + biquad filter bank, FreeRTOS SW processing task, + physical chip GPIO protocol + audio_source.h — Copied verbatim from usermods/audioreactive/ (do not edit) + library.json — PlatformIO library manifest +``` + +The engine header is designed to be included from `msgeq7.cpp` only. If the +PoC proves successful and this code is merged into audioreactive, `msgeq7_engine.h` +is the portable piece — it depends only on `wled.h` and `audio_source.h`. + +--- + ## Source attribution Biquad bandpass filter mathematics follow the Audio EQ Cookbook by Robert