-
-
Notifications
You must be signed in to change notification settings - Fork 4.1k
[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
base: main
Are you sure you want to change the base?
Changes from all commits
779a82f
5390cfc
746f8c1
9adbba6
bfb2b88
55e8868
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 |
|---|---|---|
|
|
@@ -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) | ||
|
|
@@ -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 | ||
|
|
||
|
|
@@ -1683,6 +1686,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(); | ||
|
|
@@ -1723,6 +1727,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) | ||
|
|
@@ -2223,6 +2228,69 @@ 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) | ||
| // 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 | ||
| // 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; | ||
|
Member
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. @coderabbitai what's the meaning of "500" here?
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.
Here's what it means visually and mathematically: The centroid drives the palette hue through: float logC = log2f(constrain(centroid, 60.0f, 8000.0f));
uint8_t baseHue = (uint8_t)mapf(logC, 5.9f, 13.0f, 0.0f, 200.0f);Plugging in 500 Hz:
As a sanity check, the geometric mean of the clamped range [60 Hz, 8000 Hz] on a log scale is If you'd like the silence colour to be exactly at the visual centre of the palette's hue range (hue = 100), the corresponding frequency would be 🧠 Learnings used
Member
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. could the code also use logf() instead of log2f() ? This would avoid pulling in another libm function that needs flash space on 8266.
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.
Current code: float logC = log2f(constrain(centroid, 60.0f, 8000.0f));
uint8_t baseHue = (uint8_t)mapf(logC, 5.9f, 13.0f, 0.0f, 200.0f);The constants float logC = logf(constrain(centroid, 60.0f, 8000.0f));
uint8_t baseHue = (uint8_t)mapf(logC, 4.094f, 8.987f, 0.0f, 200.0f);The output is bit-for-bit equivalent (same hue for any given centroid), and 🧠 Learnings used |
||
| // 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)); | ||
softhack007 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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: | ||
| // 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); | ||
| value = hsv; | ||
| break; | ||
| } | ||
| // AI: end | ||
| default: | ||
| if (x == 1) { | ||
| value = CRGB(fftResult[10]/2, fftResult[4]/2, fftResult[0]/2); | ||
|
|
@@ -2240,6 +2308,17 @@ void AudioReactive::fillAudioPalettes() { | |
| if (!palettes) return; | ||
| 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]); // BUG: this IIR filter assumes 20ms activation rate (which is totally wrong) | ||
|
Member
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. @coderabbitai this code only works when fillAudioPalettes() get called with a constant rate of ~20ms. However in reality the activation time of the function is largely depending on number of LEDs, and on loop() delays due to file access. The real activation period of the autdioreactive main loop() is between 0.3ms and 100ms, with peaks up to 1000ms. --> The IIR filter used here will not work under this variable activation time. Please search for ways to implement a variable activation time IIR filter that can handle this.
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.
The fix is to compute a time-adaptive alpha on each call using actual elapsed time. From the original Here is the corrected implementation for the // Replace the fixed PALETTE_SMOOTHING constant with a time constant (ms)
static constexpr float PALETTE_TIME_CONSTANT_MS = 390.0f; // ~400ms; derived from original α=0.05 at 20ms cycle
void AudioReactive::fillAudioPalettes() {
if (!palettes) return;
size_t lastCustPalette = customPalettes.size();
if (int(lastCustPalette) >= palettes) lastCustPalette -= palettes;
// Variable-rate EMA: compute alpha from actual elapsed time so the ~400ms
// time constant is preserved regardless of loop timing (0.3ms .. 1000ms).
static unsigned long lastPaletteTime = 0;
unsigned long now = millis();
unsigned long dt_ms = (lastPaletteTime > 0) ? (now - lastPaletteTime) : 20UL;
lastPaletteTime = now;
// Cap dt to 2 seconds to avoid a single "catch-up" jump after a long pause
// (e.g. file I/O, OTA). Values beyond the time constant are clamped to α≈1.
if (dt_ms > 2000) dt_ms = 2000;
// α = 1 - e^(-dt/τ) — exact discrete equivalent of a continuous RC filter
float alpha = 1.0f - expf(-(float)dt_ms / PALETTE_TIME_CONSTANT_MS);
for (int i = 0; i < NUM_GEQ_CHANNELS; i++) {
paletteBandAvg[i] += alpha * ((float)fftResult[i] - paletteBandAvg[i]);
}
// ... rest of fillAudioPalettes() unchangedWhy this works:
Also, the ✏️ Learnings added
🧠 Learnings used
Member
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. thanks, that makes sense 👍 indeed the filter parameter must be adjusted in a non-linear way, after all this IIR (or EMA) filter is also called "exponential decay filter". |
||
| } | ||
| // AI: end | ||
|
|
||
| for (int pal=0; pal<palettes; pal++) { | ||
| uint8_t tcp[16]; // Needs to be 4 times however many colors are being used. | ||
| // 3 colors = 12, 4 colors = 16, etc. | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.