From 779a82faafe44327d7c2ebee7768fb811fd0ee46 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Apr 2026 07:31:41 +0000 Subject: [PATCH 1/7] Initial plan From 5390cfcf437121c3717c57b5cd75788afd2ff23d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Apr 2026 08:01:58 +0000 Subject: [PATCH 2/7] Add two new smoothed audio-reactive palettes (Track Character + Spectral Balance) Agent-Logs-Url: https://github.com/wled/WLED/sessions/a11e5210-96ae-4713-abdd-ba42b0e33bc5 Co-authored-by: netmindz <442066+netmindz@users.noreply.github.com> --- usermods/audioreactive/audio_reactive.cpp | 56 ++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/usermods/audioreactive/audio_reactive.cpp b/usermods/audioreactive/audio_reactive.cpp index 757ad35482..594e992876 100644 --- a/usermods/audioreactive/audio_reactive.cpp +++ b/usermods/audioreactive/audio_reactive.cpp @@ -71,7 +71,7 @@ #define PLOT_PRINTF(x...) #endif -#define MAX_PALETTES 3 +#define MAX_PALETTES 5 static volatile bool disableSoundProcessing = false; // if true, sound processing (FFT, filters, AGC) will be suspended. "volatile" as its shared between tasks. static uint8_t audioSyncEnabled = 0; // bit field: bit 0 - send, bit 1 - receive (config value) @@ -226,6 +226,7 @@ static uint64_t sampleTime = 0; // FFT Task variables (filtering and post-processing) static float fftCalc[NUM_GEQ_CHANNELS] = {0.0f}; // Try and normalize fftBin values to a max of 4096, so that 4096/16 = 256. 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 for audio palettes (EMA, ~400ms time constant) #ifdef SR_DEBUG static float fftResultMax[NUM_GEQ_CHANNELS] = {0.0f}; // A table used for testing to determine how our post-processing is working. #endif @@ -1683,6 +1684,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 1.0f) ? (wSum / tEnergy) : 500.0f; + float logC = log2f(constrain(centroid, 60.0f, 8000.0f)); + // log2(60)≈5.9, log2(8000)≈13.0 → map to hue 0..200 + uint8_t baseHue = (uint8_t)mapf(logC, 5.9f, 13.0f, 0.0f, 200.0f); + // Spread palette positions around centroid hue + int8_t hueSpread = map(x, 0, 255, -30, 30); + uint8_t saturation = (uint8_t)constrain((int)(tEnergy / 6.0f) + 180, 180, 255); + hsv = CHSV(baseHue + hueSpread, saturation, (uint8_t)constrain(x, 30, 255)); + value = hsv; + break; + } + case 4: { + // "Spectral Balance" palette - bass vs mid vs high energy balance + float bassEnergy = 0, midEnergy = 0, highEnergy = 0; + for (int i = 0; i < 4; i++) bassEnergy += paletteBandAvg[i]; + for (int i = 4; i < 10; i++) midEnergy += paletteBandAvg[i]; + for (int i = 10; i < 16; i++) highEnergy += paletteBandAvg[i]; + float total = bassEnergy + midEnergy + highEnergy; + if (total < 1.0f) total = 1.0f; + float bassRatio = bassEnergy / total; + float midRatio = midEnergy / total; + float highRatio = highEnergy / total; + // Weighted hue: bass→warm(20), mid→green(110), high→cool(190) + uint8_t hue = (uint8_t)(bassRatio * 20.0f + midRatio * 110.0f + highRatio * 190.0f); + // More concentrated spectrum = more saturated + float maxRatio = fmaxf(bassRatio, fmaxf(midRatio, highRatio)); + uint8_t sat = (uint8_t)constrain((int)(maxRatio * 255.0f * 1.5f), 180, 255); + // Spread across palette position + int8_t hueOffset = map(x, 0, 255, -25, 25); + uint8_t val = (uint8_t)constrain((int)(total / 8.0f) + (int)map(x, 0, 255, 30, 255), 30, 255); + hsv = CHSV(hue + hueOffset, sat, val); + value = hsv; + break; + } } return value; } @@ -2240,6 +2287,13 @@ void AudioReactive::fillAudioPalettes() { if (!palettes) return; size_t lastCustPalette = customPalettes.size(); if (int(lastCustPalette) >= palettes) lastCustPalette -= palettes; + + // Update smoothed band averages for palettes 3 and 4 (EMA, ~400ms time constant at 20ms cycle) + static constexpr float PALETTE_SMOOTHING = 0.05f; + for (int i = 0; i < NUM_GEQ_CHANNELS; i++) { + paletteBandAvg[i] += PALETTE_SMOOTHING * ((float)fftResult[i] - paletteBandAvg[i]); + } + for (int pal=0; pal Date: Thu, 9 Apr 2026 17:12:35 +0000 Subject: [PATCH 3/7] Improve documentation of new audio palettes per reviewer feedback Agent-Logs-Url: https://github.com/wled/WLED/sessions/2dff17f8-2992-4060-8a37-5c72dd1fbf51 Co-authored-by: softhack007 <91616163+softhack007@users.noreply.github.com> --- usermods/audioreactive/audio_reactive.cpp | 64 ++++++++++++++--------- 1 file changed, 40 insertions(+), 24 deletions(-) diff --git a/usermods/audioreactive/audio_reactive.cpp b/usermods/audioreactive/audio_reactive.cpp index 594e992876..73a07ecea0 100644 --- a/usermods/audioreactive/audio_reactive.cpp +++ b/usermods/audioreactive/audio_reactive.cpp @@ -226,7 +226,8 @@ static uint64_t sampleTime = 0; // FFT Task variables (filtering and post-processing) static float fftCalc[NUM_GEQ_CHANNELS] = {0.0f}; // Try and normalize fftBin values to a max of 4096, so that 4096/16 = 256. 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 for audio palettes (EMA, ~400ms time constant) +static float paletteBandAvg[NUM_GEQ_CHANNELS] = {0.0f}; // Slowly smoothed band averages used only by audio palettes 3 & 4 (EMA, alpha=0.05 → ~400ms time constant at 20ms cycle) +static constexpr float PALETTE_SMOOTHING = 0.05f; // EMA smoothing factor for paletteBandAvg: 0.05 gives ~400ms time constant; increase for faster response, decrease for slower #ifdef SR_DEBUG static float fftResultMax[NUM_GEQ_CHANNELS] = {0.0f}; // A table used for testing to determine how our post-processing is working. #endif @@ -2235,45 +2236,58 @@ CRGB AudioReactive::getCRGBForBand(int x, int pal) { } break; case 3: { - // "Track Character" palette - smoothed spectral centroid drives hue - static const float bandFreq[NUM_GEQ_CHANNELS] = { + // "Track Character" palette (palette index 3) + // Uses the spectral centroid of paletteBandAvg[] to derive a single hue that + // reflects the tonal balance of the music over the past ~400ms: + // low centroid (bass-heavy drop) → warm red/orange (hue ≈ 0) + // mid centroid (vocals/melody) → green/cyan (hue ≈ 80-120) + // high centroid (cymbals/bright synth) → blue/purple (hue ≈ 200) + // x (0-255) spreads palette positions ±30 hue units around that base 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]; - tEnergy += paletteBandAvg[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). + // log2(60 Hz) ≈ 5.9, log2(8000 Hz) ≈ 13.0 → hue range 0..200 (red → blue-purple) float logC = log2f(constrain(centroid, 60.0f, 8000.0f)); - // log2(60)≈5.9, log2(8000)≈13.0 → map to hue 0..200 uint8_t baseHue = (uint8_t)mapf(logC, 5.9f, 13.0f, 0.0f, 200.0f); - // Spread palette positions around centroid hue - int8_t hueSpread = map(x, 0, 255, -30, 30); - uint8_t saturation = (uint8_t)constrain((int)(tEnergy / 6.0f) + 180, 180, 255); + int8_t hueSpread = map(x, 0, 255, -30, 30); // spread palette positions ±30 hue units + uint8_t saturation = (uint8_t)constrain((int)(tEnergy / 6.0f) + 180, 180, 255); // louder = more saturated hsv = CHSV(baseHue + hueSpread, saturation, (uint8_t)constrain(x, 30, 255)); value = hsv; break; } case 4: { - // "Spectral Balance" palette - bass vs mid vs high energy balance + // "Spectral Balance" palette (palette index 4) + // Divides the spectrum into three broad bands and uses their energy ratio to derive hue: + // bass dominant (channels 0-3, ~43-301 Hz) → warm hue ≈ 20 (red/orange) + // mid dominant (channels 4-9, ~301-1895 Hz) → green hue ≈ 110 (green/cyan) + // high dominant (channels 10-15, ~1895-9259 Hz)→ cool hue ≈ 190 (blue/violet) + // x (0-255) spreads palette positions ±25 hue units around that weighted hue, + // giving a smooth colour band rather than a single flat colour. float bassEnergy = 0, midEnergy = 0, highEnergy = 0; - for (int i = 0; i < 4; i++) bassEnergy += paletteBandAvg[i]; - for (int i = 4; i < 10; i++) midEnergy += paletteBandAvg[i]; - for (int i = 10; i < 16; i++) highEnergy += paletteBandAvg[i]; + 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 = bassEnergy + midEnergy + highEnergy; - if (total < 1.0f) total = 1.0f; - float bassRatio = bassEnergy / total; + if (total < 1.0f) total = 1.0f; // 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: bass→warm(20), mid→green(110), high→cool(190) - uint8_t hue = (uint8_t)(bassRatio * 20.0f + midRatio * 110.0f + highRatio * 190.0f); - // More concentrated spectrum = more saturated - float maxRatio = fmaxf(bassRatio, fmaxf(midRatio, highRatio)); - uint8_t sat = (uint8_t)constrain((int)(maxRatio * 255.0f * 1.5f), 180, 255); - // Spread across palette position - int8_t hueOffset = map(x, 0, 255, -25, 25); + // Weighted hue: pure bass→20, pure mid→110, pure high→190 + uint8_t hue = (uint8_t)(bassRatio * 20.0f + midRatio * 110.0f + highRatio * 190.0f); + // Saturation: dominated spectrum (one band clearly wins) → high sat; balanced → lower sat + float maxRatio = fmaxf(bassRatio, fmaxf(midRatio, highRatio)); + uint8_t sat = (uint8_t)constrain((int)(maxRatio * 255.0f * 1.5f), 180, 255); + int8_t hueOffset = map(x, 0, 255, -25, 25); // spread palette positions ±25 hue units + // brightness: minimum 30, boosted by overall loudness and palette position uint8_t val = (uint8_t)constrain((int)(total / 8.0f) + (int)map(x, 0, 255, 30, 255), 30, 255); hsv = CHSV(hue + hueOffset, sat, val); value = hsv; @@ -2288,8 +2302,10 @@ void AudioReactive::fillAudioPalettes() { size_t lastCustPalette = customPalettes.size(); if (int(lastCustPalette) >= palettes) lastCustPalette -= palettes; - // Update smoothed band averages for palettes 3 and 4 (EMA, ~400ms time constant at 20ms cycle) - static constexpr float PALETTE_SMOOTHING = 0.05f; + // Update slowly-smoothed band averages used by palettes 3 & 4. + // Alpha=PALETTE_SMOOTHING gives ~400ms time constant at a 20ms update cycle, + // so palette colours reflect the overall tonal character of the music rather than + // reacting to individual beats (which would appear "twitchy"). for (int i = 0; i < NUM_GEQ_CHANNELS; i++) { paletteBandAvg[i] += PALETTE_SMOOTHING * ((float)fftResult[i] - paletteBandAvg[i]); } From bfb2b885597435bc56d9453f84a17111f8ede0f5 Mon Sep 17 00:00:00 2001 From: Frank <91616163+softhack007@users.noreply.github.com> Date: Fri, 10 Apr 2026 00:51:31 +0200 Subject: [PATCH 4/7] fix some bugs, mark some leftover errors * move paletteBandAvg out of esp32-only section (fixes 8266 build error) * style: move default case _below_ other cases * add missing "generated by AI" labels * mark some leftover bugs --- usermods/audioreactive/audio_reactive.cpp | 45 +++++++++++++---------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/usermods/audioreactive/audio_reactive.cpp b/usermods/audioreactive/audio_reactive.cpp index 73a07ecea0..388faf94a6 100644 --- a/usermods/audioreactive/audio_reactive.cpp +++ b/usermods/audioreactive/audio_reactive.cpp @@ -98,6 +98,9 @@ static bool udpSamplePeak = false; // Boolean flag for peak. Set at the same t static unsigned long timeOfPeak = 0; // time of last sample peak detection. static uint8_t fftResult[NUM_GEQ_CHANNELS]= {0};// Our calculated freq. channel result table to be used by effects +static float paletteBandAvg[NUM_GEQ_CHANNELS] = {0.0f}; // Slowly smoothed band averages used only by audio palettes 3 & 4 (EMA, alpha=0.05 → ~400ms time constant at 20ms cycle) +static constexpr float PALETTE_SMOOTHING = 0.05f; // EMA smoothing factor for paletteBandAvg: 0.05 gives ~400ms time constant; increase for faster response, decrease for slower + // TODO: probably best not used by receive nodes //static float agcSensitivity = 128; // AGC sensitivity estimation, based on agc gain (multAgc). calculated by getSensitivity(). range 0..255 @@ -226,8 +229,6 @@ static uint64_t sampleTime = 0; // FFT Task variables (filtering and post-processing) static float fftCalc[NUM_GEQ_CHANNELS] = {0.0f}; // Try and normalize fftBin values to a max of 4096, so that 4096/16 = 256. 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, alpha=0.05 → ~400ms time constant at 20ms cycle) -static constexpr float PALETTE_SMOOTHING = 0.05f; // EMA smoothing factor for paletteBandAvg: 0.05 gives ~400ms time constant; increase for faster response, decrease for slower #ifdef SR_DEBUG static float fftResultMax[NUM_GEQ_CHANNELS] = {0.0f}; // A table used for testing to determine how our post-processing is working. #endif @@ -2226,19 +2227,11 @@ CRGB AudioReactive::getCRGBForBand(int x, int pal) { hsv = CHSV(fftResult[b], 255, map(fftResult[b], 0, 255, 30, 255)); // pick hue value = hsv; // convert to R,G,B break; - default: - if (x == 1) { - value = CRGB(fftResult[10]/2, fftResult[4]/2, fftResult[0]/2); - } else if(x == 255) { - value = CRGB(fftResult[10]/2, fftResult[0]/2, fftResult[4]/2); - } else { - value = CRGB(fftResult[0]/2, fftResult[4]/2, fftResult[10]/2); - } - break; + // AI: below section was generated by an AI case 3: { // "Track Character" palette (palette index 3) // Uses the spectral centroid of paletteBandAvg[] to derive a single hue that - // reflects the tonal balance of the music over the past ~400ms: + // reflects the tonal balance of the music over the past ~400ms: // softhack007 this statement is wrong // low centroid (bass-heavy drop) → warm red/orange (hue ≈ 0) // mid centroid (vocals/melody) → green/cyan (hue ≈ 80-120) // high centroid (cymbals/bright synth) → blue/purple (hue ≈ 200) @@ -2256,14 +2249,16 @@ CRGB AudioReactive::getCRGBForBand(int x, int pal) { float centroid = (tEnergy > 1.0f) ? (wSum / tEnergy) : 500.0f; // Map centroid to hue on a log scale (human pitch perception is logarithmic). // log2(60 Hz) ≈ 5.9, log2(8000 Hz) ≈ 13.0 → hue range 0..200 (red → blue-purple) - float logC = log2f(constrain(centroid, 60.0f, 8000.0f)); + float logC = log2f(constrain(centroid, 60.0f, 8000.0f)); // softhack007 ToDO: use logf() instead of log2f() uint8_t baseHue = (uint8_t)mapf(logC, 5.9f, 13.0f, 0.0f, 200.0f); - int8_t hueSpread = map(x, 0, 255, -30, 30); // spread palette positions ±30 hue units - uint8_t saturation = (uint8_t)constrain((int)(tEnergy / 6.0f) + 180, 180, 255); // louder = more saturated + int8_t hueSpread = map(x, 0, 255, -30, 30); // spread palette positions ±30 hue units // softhack007 ToDO: use CHSV32 with 16bit HUE + uint8_t saturation = (uint8_t)constrain((int)(tEnergy / 6.0f) + 180, 180, 255); // louder = more saturated // softhack007 WTF dude? hsv = CHSV(baseHue + hueSpread, saturation, (uint8_t)constrain(x, 30, 255)); value = hsv; break; } + // AI: end + // AI: below section was generated by an AI case 4: { // "Spectral Balance" palette (palette index 4) // Divides the spectrum into three broad bands and uses their energy ratio to derive hue: @@ -2284,15 +2279,25 @@ CRGB AudioReactive::getCRGBForBand(int x, int pal) { // Weighted hue: pure bass→20, pure mid→110, pure high→190 uint8_t hue = (uint8_t)(bassRatio * 20.0f + midRatio * 110.0f + highRatio * 190.0f); // Saturation: dominated spectrum (one band clearly wins) → high sat; balanced → lower sat - float maxRatio = fmaxf(bassRatio, fmaxf(midRatio, highRatio)); - uint8_t sat = (uint8_t)constrain((int)(maxRatio * 255.0f * 1.5f), 180, 255); - int8_t hueOffset = map(x, 0, 255, -25, 25); // spread palette positions ±25 hue units + float maxRatio = max(bassRatio, max(midRatio, highRatio)); + uint8_t sat = (uint8_t)constrain((int)(maxRatio * 255.0f * 1.5f), 180, 255); // softhack007 OMG, WTF? + int8_t hueOffset = map(x, 0, 255, -25, 25); // spread palette positions ±25 hue units // softhack007 ToDO: use CHSV32 with 16bit HUE // brightness: minimum 30, boosted by overall loudness and palette position uint8_t val = (uint8_t)constrain((int)(total / 8.0f) + (int)map(x, 0, 255, 30, 255), 30, 255); hsv = CHSV(hue + hueOffset, sat, val); value = hsv; break; } + // AI: end + default: + if (x == 1) { + value = CRGB(fftResult[10]/2, fftResult[4]/2, fftResult[0]/2); + } else if(x == 255) { + value = CRGB(fftResult[10]/2, fftResult[0]/2, fftResult[4]/2); + } else { + value = CRGB(fftResult[0]/2, fftResult[4]/2, fftResult[10]/2); + } + break; } return value; } @@ -2302,13 +2307,15 @@ void AudioReactive::fillAudioPalettes() { size_t lastCustPalette = customPalettes.size(); if (int(lastCustPalette) >= palettes) lastCustPalette -= palettes; + // AI: below section was generated by an AI // Update slowly-smoothed band averages used by palettes 3 & 4. // Alpha=PALETTE_SMOOTHING gives ~400ms time constant at a 20ms update cycle, // so palette colours reflect the overall tonal character of the music rather than // reacting to individual beats (which would appear "twitchy"). for (int i = 0; i < NUM_GEQ_CHANNELS; i++) { - paletteBandAvg[i] += PALETTE_SMOOTHING * ((float)fftResult[i] - paletteBandAvg[i]); + paletteBandAvg[i] += PALETTE_SMOOTHING * ((float)fftResult[i] - paletteBandAvg[i]); // BUG: this IIR filter assumes 20ms activation rate (which is totally wrong) } + // AI: end for (int pal=0; pal Date: Fri, 10 Apr 2026 01:52:07 +0200 Subject: [PATCH 5/7] fix a few more AI-generated bugs WTF, dude --- usermods/audioreactive/audio_reactive.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/usermods/audioreactive/audio_reactive.cpp b/usermods/audioreactive/audio_reactive.cpp index 388faf94a6..013cb96398 100644 --- a/usermods/audioreactive/audio_reactive.cpp +++ b/usermods/audioreactive/audio_reactive.cpp @@ -1727,6 +1727,7 @@ class AudioReactive : public Usermod { // reset sound data volumeRaw = 0; volumeSmth = 0; for(int i=(init?0:1); i safe to directly cast to uint8_t int8_t hueSpread = map(x, 0, 255, -30, 30); // spread palette positions ±30 hue units // softhack007 ToDO: use CHSV32 with 16bit HUE uint8_t saturation = (uint8_t)constrain((int)(tEnergy / 6.0f) + 180, 180, 255); // louder = more saturated // softhack007 WTF dude? hsv = CHSV(baseHue + hueSpread, saturation, (uint8_t)constrain(x, 30, 255)); @@ -2277,7 +2278,8 @@ CRGB AudioReactive::getCRGBForBand(int x, int pal) { float midRatio = midEnergy / total; float highRatio = highEnergy / total; // Weighted hue: pure bass→20, pure mid→110, pure high→190 - uint8_t hue = (uint8_t)(bassRatio * 20.0f + midRatio * 110.0f + highRatio * 190.0f); + int weightedHue = roundf(bassRatio * 20.0f + midRatio * 110.0f + highRatio * 190.0f); + uint8_t hue = min(255, max(0, weightedHue)); // clamp to [0...255] // Saturation: dominated spectrum (one band clearly wins) → high sat; balanced → lower sat float maxRatio = max(bassRatio, max(midRatio, highRatio)); uint8_t sat = (uint8_t)constrain((int)(maxRatio * 255.0f * 1.5f), 180, 255); // softhack007 OMG, WTF? From f861e166470525c248b87c62ec2418d9392c7f22 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Jul 2026 17:07:36 +0000 Subject: [PATCH 6/7] Resolve merge conflict in audio_reactive.cpp --- usermods/audioreactive/audio_reactive.cpp | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/usermods/audioreactive/audio_reactive.cpp b/usermods/audioreactive/audio_reactive.cpp index f6ecb14ec9..a55b548b43 100644 --- a/usermods/audioreactive/audio_reactive.cpp +++ b/usermods/audioreactive/audio_reactive.cpp @@ -889,6 +889,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[]; static const char UDP_SYNC_HEADER[]; static const char UDP_SYNC_HEADER_v1[]; @@ -2232,7 +2234,7 @@ void AudioReactive::removeAudioPalettes(void) { void AudioReactive::createAudioPalettes(void) { if (palettes) return; DEBUG_PRINTLN(F("Adding audio palettes.")); - static const char *const palNames[MAX_PALETTES] PROGMEM = {_palName0, _palName1, _palName2}; + static const char *const palNames[MAX_PALETTES] PROGMEM = {_palName0, _palName1, _palName2, _palName3, _palName4}; for (int i=0; i= palettes) lastCustPalette -= palettes; // AI: below section was generated by an AI // Update slowly-smoothed band averages used by palettes 3 & 4. @@ -2350,13 +2349,10 @@ void AudioReactive::fillAudioPalettes() { } // AI: end - for (int pal=0; pal>>>>>> origin/main uint8_t tcp[16]; // Needs to be 4 times however many colors are being used. // 3 colors = 12, 4 colors = 16, etc. @@ -2402,6 +2398,8 @@ 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"; const char AudioReactive::UDP_SYNC_HEADER[] PROGMEM = "00002"; // new sync header version, as format no longer compatible with previous structure const char AudioReactive::UDP_SYNC_HEADER_v1[] PROGMEM = "00001"; // old sync header version - need to add backwards-compatibility feature From 309aecee03d813bf52664dcc0d04aba84fe83e49 Mon Sep 17 00:00:00 2001 From: Will Tatam Date: Fri, 11 Sep 2026 18:50:11 +0100 Subject: [PATCH 7/7] Fix audio palette review feedback: variable-cadence EMA, hue wraparound, saturation scaling softhack007 requested changes because the paletteBandAvg EMA filter assumed a fixed ~20ms fillAudioPalettes() cadence, but the audioreactive loop actually runs at 0.3ms-1000ms depending on LED count and file/OTA activity. Replace the fixed alpha with a time-adaptive one derived from elapsed time, hoist the per-band aggregate math out of getCRGBForBand() so it isn't redone on every call, fix a uint8_t wraparound in the hue math, and rescale the saturation/brightness formulas so they vary across realistic loudness ranges instead of pinning at a clamp. Also swap log2f for logf (already linked elsewhere in this file) to avoid an extra libm symbol on ESP8266. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01RVPeM1wn7RhErohRxce1bJ --- usermods/audioreactive/audio_reactive.cpp | 157 +++++++++++++++------- 1 file changed, 110 insertions(+), 47 deletions(-) diff --git a/usermods/audioreactive/audio_reactive.cpp b/usermods/audioreactive/audio_reactive.cpp index 441928deee..2eda7b32eb 100644 --- a/usermods/audioreactive/audio_reactive.cpp +++ b/usermods/audioreactive/audio_reactive.cpp @@ -137,8 +137,15 @@ static bool udpSamplePeak = false; // Boolean flag for peak. Set at the same t static unsigned long timeOfPeak = 0; // time of last sample peak detection. static uint8_t fftResult[NUM_GEQ_CHANNELS]= {0};// Our calculated freq. channel result table to be used by effects -static float paletteBandAvg[NUM_GEQ_CHANNELS] = {0.0f}; // Slowly smoothed band averages used only by audio palettes 3 & 4 (EMA, alpha=0.05 → ~400ms time constant at 20ms cycle) -static constexpr float PALETTE_SMOOTHING = 0.05f; // EMA smoothing factor for paletteBandAvg: 0.05 gives ~400ms time constant; increase for faster response, decrease for slower +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) // TODO: probably best not used by receive nodes //static float agcSensitivity = 128; // AGC sensitivity estimation, based on agc gain (multAgc). calculated by getSensitivity(). range 0..255 @@ -2300,30 +2307,22 @@ CRGB AudioReactive::getCRGBForBand(int x, int pal) { // AI: below section was generated by an AI case 3: { // "Track Character" palette (palette index 3) - // Uses the spectral centroid of paletteBandAvg[] to derive a single hue that - // reflects the tonal balance of the music over the past ~400ms: // softhack007 this statement is wrong + // Hue reflects the spectral centroid of paletteBandAvg[] (smoothed with a ~390ms + // time constant, see fillAudioPalettes()), i.e. the tonal balance of the music: // low centroid (bass-heavy drop) → warm red/orange (hue ≈ 0) // mid centroid (vocals/melody) → green/cyan (hue ≈ 80-120) // high centroid (cymbals/bright synth) → blue/purple (hue ≈ 200) // x (0-255) spreads palette positions ±30 hue units around that base 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). - // log2(60 Hz) ≈ 5.9, log2(8000 Hz) ≈ 13.0 → hue range 0..200 (red → blue-purple) - float logC = log2f(constrain(centroid, 60.0f, 8000.0f)); // softhack007 ToDO: use logf() instead of log2f() - uint8_t baseHue = (uint8_t)mapf(logC, 5.9f, 13.0f, 0.0f, 200.0f); // mapf() cannot produce negative results due to previous constrain() --> safe to directly cast to uint8_t - int8_t hueSpread = map(x, 0, 255, -30, 30); // spread palette positions ±30 hue units // softhack007 ToDO: use CHSV32 with 16bit HUE - uint8_t saturation = (uint8_t)constrain((int)(tEnergy / 6.0f) + 180, 180, 255); // louder = more saturated // softhack007 WTF dude? - hsv = CHSV(baseHue + hueSpread, saturation, (uint8_t)constrain(x, 30, 255)); + // baseHue and saturation are precomputed once per refresh in fillAudioPalettes() + // (they don't depend on x, so there's no need to redo the 16-channel scan here + // on every one of the 3 calls per refresh). + // TODO: use CHSV32 with 16-bit hue for finer resolution + int8_t hueSpread = map(x, 0, 255, -30, 30); // spread palette positions ±30 hue units + // paletteHue3 ∈ [0,200] and hueSpread ∈ [-30,30]; the sum can go negative, so clamp + // through a signed int before narrowing to uint8_t instead of letting it silently + // wrap around (which would turn the warm/bass end of the gradient into purple). + uint8_t hue = (uint8_t)constrain((int)paletteHue3 + hueSpread, 0, 255); + hsv = CHSV(hue, paletteSat3, (uint8_t)constrain(x, 30, 255)); value = hsv; break; } @@ -2331,31 +2330,26 @@ CRGB AudioReactive::getCRGBForBand(int x, int pal) { // AI: below section was generated by an AI case 4: { // "Spectral Balance" palette (palette index 4) - // Divides the spectrum into three broad bands and uses their energy ratio to derive hue: + // Divides the spectrum into three broad bands and uses their smoothed energy ratio + // (paletteBandAvg[], ~390ms time constant) to derive hue: // bass dominant (channels 0-3, ~43-301 Hz) → warm hue ≈ 20 (red/orange) // mid dominant (channels 4-9, ~301-1895 Hz) → green hue ≈ 110 (green/cyan) // high dominant (channels 10-15, ~1895-9259 Hz)→ cool hue ≈ 190 (blue/violet) // x (0-255) spreads palette positions ±25 hue units around that weighted hue, // giving a smooth colour band rather than a single flat colour. - 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 = bassEnergy + midEnergy + highEnergy; - if (total < 1.0f) total = 1.0f; // 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 = roundf(bassRatio * 20.0f + midRatio * 110.0f + highRatio * 190.0f); - uint8_t hue = min(255, max(0, weightedHue)); // clamp to [0...255] - // Saturation: dominated spectrum (one band clearly wins) → high sat; balanced → lower sat - float maxRatio = max(bassRatio, max(midRatio, highRatio)); - uint8_t sat = (uint8_t)constrain((int)(maxRatio * 255.0f * 1.5f), 180, 255); // softhack007 OMG, WTF? - int8_t hueOffset = map(x, 0, 255, -25, 25); // spread palette positions ±25 hue units // softhack007 ToDO: use CHSV32 with 16bit HUE - // brightness: minimum 30, boosted by overall loudness and palette position - uint8_t val = (uint8_t)constrain((int)(total / 8.0f) + (int)map(x, 0, 255, 30, 255), 30, 255); - hsv = CHSV(hue + hueOffset, sat, val); + // paletteHue4/paletteSat4/paletteTotal4 are precomputed once per refresh in + // fillAudioPalettes() (they don't depend on x). + // TODO: use CHSV32 with 16-bit hue for finer resolution + int8_t hueOffset = map(x, 0, 255, -25, 25); // spread palette positions ±25 hue units + // paletteHue4 ∈ [20,190] and hueOffset ∈ [-25,25]; clamp through a signed int + // before narrowing to uint8_t so the sum can't silently wrap around uint8_t. + uint8_t hue = (uint8_t)constrain((int)paletteHue4 + hueOffset, 0, 255); + // brightness: minimum 30, boosted by overall loudness (average band value, same 0..255 + // scale as any individual band - see paletteSat3's comment for why AVERAGE rather than + // the raw SUM in paletteTotal4 is used as the loudness reference) and palette position + float avgEnergy4 = paletteTotal4 / (float)NUM_GEQ_CHANNELS; + uint8_t val = (uint8_t)constrain((int)mapf(avgEnergy4, 0.0f, 255.0f, 0.0f, 100.0f) + (int)map(x, 0, 255, 30, 255), 30, 255); + hsv = CHSV(hue, paletteSat4, val); value = hsv; break; } @@ -2377,12 +2371,81 @@ void AudioReactive::fillAudioPalettes() { if (!palettes) return; // AI: below section was generated by an AI - // Update slowly-smoothed band averages used by palettes 3 & 4. - // Alpha=PALETTE_SMOOTHING gives ~400ms time constant at a 20ms update cycle, - // so palette colours reflect the overall tonal character of the music rather than - // reacting to individual beats (which would appear "twitchy"). + // Update slowly-smoothed band averages used by palettes 3 & 4, so palette colours + // reflect the overall tonal character of the music rather than reacting to individual + // beats (which would appear "twitchy"). fillAudioPalettes() is called once per + // AudioReactive::loop() iteration, which runs at a highly variable cadence - roughly + // 0.3ms to 1000ms depending on LED count and file/OTA activity - not a fixed rate, so + // the EMA's alpha is derived from the actual elapsed time on every call rather than + // being a fixed constant tuned for one assumed cadence. + // alpha = dt / (tau + dt) approximates the exact alpha = 1 - exp(-dt/tau) for a given + // time constant tau without pulling in expf() (logf() is already linked into this + // file elsewhere; expf() would not be). + static unsigned long lastPaletteTime = 0; + unsigned long now = millis(); + unsigned long dtMs = lastPaletteTime ? (now - lastPaletteTime) : 20UL; + lastPaletteTime = now; + if (dtMs > 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] += PALETTE_SMOOTHING * ((float)fftResult[i] - paletteBandAvg[i]); // BUG: this IIR filter assumes 20ms activation rate (which is totally wrong) + 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