Repository navigation
[WIP] Add new audio-reactive palettes for smoothed FFT data #5467
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
779a82f
5390cfc
746f8c1
9adbba6
bfb2b88
55e8868
09faf4c
8ac50e2
f861e16
fedec42
309aece
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -110,7 +110,7 @@ extern "C" esp_err_t adc1_lock_release(void) { | |
| #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) | ||
|
|
@@ -137,6 +137,16 @@ 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, ~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 | ||
|
|
||
|
|
@@ -923,6 +933,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[]; | ||
|
|
||
|
|
@@ -1738,6 +1750,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<NUM_GEQ_CHANNELS; i+=2) fftResult[i] = 16; // make a tiny pattern | ||
| inputLevel = 128; // reset level slider to default | ||
| autoResetPeak(); | ||
|
|
@@ -1778,6 +1791,7 @@ class AudioReactive : public Usermod { | |
| // reset sound data | ||
| volumeRaw = 0; volumeSmth = 0; | ||
| for(int i=(init?0:1); i<NUM_GEQ_CHANNELS; i+=2) fftResult[i] = 16; // make a tiny pattern | ||
| memset(paletteBandAvg, 0, sizeof(paletteBandAvg)); | ||
| autoResetPeak(); | ||
| if (init) { | ||
| if (udpSyncConnected) { // close UDP sync connection (if open) | ||
|
|
@@ -2264,7 +2278,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<MAX_PALETTES; i++) { | ||
| if (usermodPalettes.size() < WLED_MAX_USERMOD_PALETTES) { | ||
| usermodPalettes.push_back({CRGBPalette16(CRGB(BLACK)), _name, (uint8_t)i, palNames[i]}); // start black, filled each loop by fillAudioPalettes() | ||
|
|
@@ -2290,6 +2304,56 @@ 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; | ||
| // AI: below section was generated by an AI | ||
| case 3: { | ||
| // "Track Character" palette (palette index 3) | ||
| // 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. | ||
| // 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; | ||
| } | ||
| // 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 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. | ||
| // 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; | ||
| } | ||
| // AI: end | ||
| default: | ||
| if (x == 1) { | ||
| value = CRGB(fftResult[10]/2, fftResult[4]/2, fftResult[0]/2); | ||
|
|
@@ -2305,6 +2369,86 @@ CRGB AudioReactive::getCRGBForBand(int x, int pal) { | |
|
|
||
| void AudioReactive::fillAudioPalettes() { | ||
| if (!palettes) return; | ||
|
|
||
| // AI: below section was generated by an AI | ||
| // 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); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Use the exact elapsed-time EMA coefficient.
Proposed fix- float alpha = (float)dtMs / (PALETTE_TIME_CONSTANT_MS + (float)dtMs);
+ float alpha = 1.0f - expf(-(float)dtMs / PALETTE_TIME_CONSTANT_MS);🤖 Prompt for AI Agents |
||
| 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; | ||
|
|
@@ -2354,6 +2498,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 | ||
|
|
||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.