diff --git a/.github/workflows/build-wled-mm.yml b/.github/workflows/build-wled-mm.yml new file mode 100644 index 00000000..16196e2f --- /dev/null +++ b/.github/workflows/build-wled-mm.yml @@ -0,0 +1,43 @@ +name: Build WLED-MM + +on: + push: + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout this repo + uses: actions/checkout@v4 + + - name: Checkout WLED-MM + uses: actions/checkout@v4 + with: + repository: MoonModules/WLED-MM + path: WLED-MM + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: Install PlatformIO + run: pip install platformio + + - name: Override AR_lib_deps to use this commit + env: + AR_REPO: ${{ github.event.pull_request.head.repo.full_name || github.repository }} + AR_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + cat > WLED-MM/platformio_override.ini << EOF + [common_mm] + AR_lib_deps = + https://github.com/${AR_REPO}.git#${AR_SHA} + https://github.com/softhack007/arduinoFFT.git#56c867c + EOF + + - name: Build + run: | + cd WLED-MM + pio run -e esp32_4MB_V4_S diff --git a/audio_reactive.h b/audio_reactive.h index 672cbdf0..8638caa9 100644 --- a/audio_reactive.h +++ b/audio_reactive.h @@ -145,7 +145,7 @@ static bool audioSyncBroadcast = false; // if true, use subnet static bool udpSyncConnected = false; // UDP connection status -> true if connected to UDP sync #define NUM_GEQ_CHANNELS 16 // number of frequency channels. Don't change !! -#define MAX_PALETTES 3 // number of audioreactive palettes provided by this usermod +#define MAX_PALETTES 5 // number of audioreactive palettes provided by this usermod // audioreactive variables #ifdef ARDUINO_ARCH_ESP32 @@ -176,6 +176,16 @@ static uint8_t fftResult[NUM_GEQ_CHANNELS]= {0}; // Our calculated freq. chann static float fftCalc[NUM_GEQ_CHANNELS] = {0.0f}; // Try and normalize fftBin values to a max of 4096, so that 4096/16 = 256. (also used by dynamics limiter) static float fftAvg[NUM_GEQ_CHANNELS] = {0.0f}; // Calculated frequency channel results, with smoothing (used if dynamics limiter is ON) +static float paletteBandAvg[NUM_GEQ_CHANNELS] = {0.0f}; // Slowly smoothed band averages used only by audio palettes 3 & 4 (EMA, ~390ms time constant, cadence-independent - see fillAudioPalettes()) +static constexpr float PALETTE_TIME_CONSTANT_MS = 390.0f; // time constant for paletteBandAvg smoothing; derived from original alpha=0.05 @ 20ms cycle (tau = -20ms / ln(1-0.05)) +// Aggregates derived from paletteBandAvg[], precomputed once per fillAudioPalettes() refresh +// for use by getCRGBForBand() palettes 3 & 4 (see there for details). +static uint8_t paletteHue3 = 0; // case 3: spectral-centroid hue +static uint8_t paletteSat3 = 180; // case 3: loudness-derived saturation +static uint8_t paletteHue4 = 0; // case 4: bass/mid/high-weighted hue +static uint8_t paletteSat4 = 180; // case 4: dominance-derived saturation +static float paletteTotal4 = 0.0f;// case 4: total band energy (drives brightness) + static uint16_t zeroCrossingCount = 0; // number of zero crossings in the current batch of 512 samples // TODO: probably best not used by receive nodes @@ -1121,11 +1131,6 @@ class AudioReactive : public Usermod { private: #ifdef ARDUINO_ARCH_ESP32 -// HUB75 workaround - audio receive only -#ifdef WLED_ENABLE_HUB75MATRIX -#undef SR_DMTYPE -#define SR_DMTYPE 254 // "network receive only" -#endif #ifndef AUDIOPIN int8_t audioPin = -1; #else @@ -1261,6 +1266,8 @@ class AudioReactive : public Usermod { static const char _palName0[]; static const char _palName1[]; static const char _palName2[]; + static const char _palName3[]; + static const char _palName4[]; // private methods void removeAudioPalettes(void); @@ -2506,6 +2513,7 @@ class AudioReactive : public Usermod { memset(fftCalc, 0, sizeof(fftCalc)); memset(fftAvg, 0, sizeof(fftAvg)); memset(fftResult, 0, sizeof(fftResult)); + memset(paletteBandAvg, 0, sizeof(paletteBandAvg)); for(int i=(init?0:1); i 2000UL) dtMs = 2000UL; // cap a single "catch-up" jump after a long stall (OTA, file I/O) + float alpha = (float)dtMs / (PALETTE_TIME_CONSTANT_MS + (float)dtMs); + for (int i = 0; i < NUM_GEQ_CHANNELS; i++) { + paletteBandAvg[i] += alpha * ((float)fftResult[i] - paletteBandAvg[i]); + } + + // Precompute the x-independent aggregates for palettes 3 & 4 once per refresh (see + // getCRGBForBand() cases 3 & 4), instead of redoing these 16-channel scans on each of + // the 3 getCRGBForBand() calls (x=1/128/255) made below for every refresh. + { + // Palette 3 ("Track Character"): spectral centroid → hue + static const float bandFreq[NUM_GEQ_CHANNELS] = { // approximate centre frequency (Hz) of each GEQ channel + 65, 107, 172, 258, 365, 495, 689, 969, + 1270, 1658, 2153, 2713, 3359, 4091, 5792, 8182 + }; + float wSum = 0, tEnergy = 0; + for (int i = 0; i < NUM_GEQ_CHANNELS; i++) { + wSum += paletteBandAvg[i] * bandFreq[i]; // frequency-weighted energy + tEnergy += paletteBandAvg[i]; // total energy + } + // centroid = energy-weighted average frequency; default to 500 Hz when signal is silent + float centroid = (tEnergy > 1.0f) ? (wSum / tEnergy) : 500.0f; + // Map centroid to hue on a log scale (human pitch perception is logarithmic). + // ln(60 Hz) ≈ 4.0943, ln(8000 Hz) ≈ 8.9872 → hue range 0..200 (red → blue-purple). + // Using logf() rather than log2f() avoids pulling in a second libm log function; + // logf() is already linked into this file elsewhere. Bounds are rounded outward so + // mapf()'s result can't go slightly negative. + float logC = logf(constrain(centroid, 60.0f, 8000.0f)); + paletteHue3 = (uint8_t)mapf(logC, 4.0943f, 8.9872f, 0.0f, 200.0f); // mapf() cannot produce negative results due to previous constrain() --> safe to directly cast to uint8_t + // Saturation rises with overall loudness. tEnergy is the SUM of 16 EMA'd bands, so its + // theoretical ceiling is 16*255=4080 - but real music never drives all 16 log-spaced + // bands to full scale simultaneously, so scaling against that ceiling pins saturation + // near its floor for any real signal. Use the AVERAGE band value instead (tEnergy/16), + // which is on the same 0..255 scale that any individual band is treated on elsewhere + // in this file (e.g. the `case 1`/`case 2` palettes above), so saturation actually + // varies across a musically realistic loudness range. + float avgEnergy3 = tEnergy / (float)NUM_GEQ_CHANNELS; + paletteSat3 = (uint8_t)constrain((int)mapf(avgEnergy3, 0.0f, 255.0f, 180.0f, 255.0f), 180, 255); + + // Palette 4 ("Spectral Balance"): bass/mid/high energy ratio → hue. + // Reuses tEnergy (== bassEnergy+midEnergy+highEnergy, since channels 0-15 are already + // summed above) as the total instead of re-summing it, to avoid two full 16-channel + // sums doing the same addition per refresh. + float bassEnergy = 0, midEnergy = 0, highEnergy = 0; + for (int i = 0; i < 4; i++) bassEnergy += paletteBandAvg[i]; // sub-bass + bass + for (int i = 4; i < 10; i++) midEnergy += paletteBandAvg[i]; // midrange + for (int i = 10; i < 16; i++) highEnergy += paletteBandAvg[i]; // high-mid + high + float total = (tEnergy < 1.0f) ? 1.0f : tEnergy; // avoid division by zero when silent + float bassRatio = bassEnergy / total; // fraction of energy in bass band + float midRatio = midEnergy / total; + float highRatio = highEnergy / total; + // Weighted hue: pure bass→20, pure mid→110, pure high→190 + int weightedHue = (int)roundf(bassRatio * 20.0f + midRatio * 110.0f + highRatio * 190.0f); + paletteHue4 = (uint8_t)constrain(weightedHue, 0, 255); + // Saturation: a clearly dominant band → high sat; a balanced spectrum → lower sat. + // maxRatio is the max of three ratios that sum to 1, so its real range is [1/3, 1], + // not [0, 1]; map that actual range onto [180, 255] instead of an arbitrary factor, + // so saturation actually varies instead of floor/ceiling-clamping almost always. + float maxRatio = fmaxf(bassRatio, fmaxf(midRatio, highRatio)); + paletteSat4 = (uint8_t)constrain((int)mapf(maxRatio, 1.0f/3.0f, 1.0f, 180.0f, 255.0f), 180, 255); + paletteTotal4 = total; + } + // AI: end + // Scan by name pointer identity to find the palettes we added, palIndex = 0/1/2... selects the getCRGBForBand variant, matching how the entries were created. for (auto &ump : usermodPalettes) { if (ump.name != _name) continue; @@ -3424,3 +3563,5 @@ const char AudioReactive::_addPalettes[] PROGMEM = "add-palettes"; const char AudioReactive::_palName0[] PROGMEM = "Ratio"; const char AudioReactive::_palName1[] PROGMEM = "Hue"; const char AudioReactive::_palName2[] PROGMEM = "Spectrum"; +const char AudioReactive::_palName3[] PROGMEM = "Track Character"; +const char AudioReactive::_palName4[] PROGMEM = "Spectral Balance";