Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 168 additions & 10 deletions audio_reactive.h
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,12 @@ static bool udpSyncConnected = false; // UDP connection stat
static volatile bool isOOM = false; // FFTask: not enough memory for buffers (audio processing failed to start)

#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

// helper functions
static float my_mapf(float x, float in_min, float in_max, float out_min, float out_max){ // float version of map()
return (x - in_min) * (out_max - out_min) / (in_max - in_min) + out_min;
}

// audioreactive variables
#ifdef ARDUINO_ARCH_ESP32
Expand Down Expand Up @@ -232,6 +237,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
Expand Down Expand Up @@ -259,7 +274,6 @@ static void detectSamplePeak(void); // peak detection function (needs scaled FF
static void autoResetPeak(void); // peak auto-reset function
static uint8_t maxVol = 31; // (was 10) Reasonable value for constant volume for 'peak detector', as it won't always trigger (deprecated)
static uint8_t binNum = 8; // Used to select the bin for FFT based beat detection (deprecated)

#ifdef ARDUINO_ARCH_ESP32

// use audio source class (ESP32 specific)
Expand Down Expand Up @@ -347,7 +361,6 @@ static volatile float micReal_max2 = 0.0f; // MicIn data max afte
////////////////////

// some prototypes, to ensure consistent interfaces
static float my_mapf(float x, float in_min, float in_max, float out_min, float out_max); // map function for float
static float fftAddAvg(int from, int to); // average of several FFT result bins
void FFTcode(void * parameter); // audio processing task: read samples, run FFT, fill GEQ channels from FFT results
static void runMicFilter(uint16_t numSamples, float *sampleBuffer); // pre-filtering of raw samples (band-pass)
Expand Down Expand Up @@ -480,12 +493,6 @@ constexpr float binWidth = SAMPLE_RATE / (float)samplesFFT; // frequency range o
#include <arduinoFFT.h>

// Helper functions

// float version of map()
static float my_mapf(float x, float in_min, float in_max, float out_min, float out_max){
return (x - in_min) * (out_max - out_min) / (in_max - in_min) + out_min;
}

// compute average of several FFT result bins
// linear average
static float fftAddAvgLin(int from, int to) {
Expand Down Expand Up @@ -1371,6 +1378,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);
Expand Down Expand Up @@ -2636,6 +2645,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();
Expand Down Expand Up @@ -2688,6 +2698,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) {
Expand Down Expand Up @@ -3489,7 +3500,7 @@ void AudioReactive::createAudioPalettes(void) {
#ifndef _MoonModules_WLED_
if (palettes || processingPalettes) 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()
Expand Down Expand Up @@ -3517,6 +3528,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)my_mapf(avgEnergy4, 0.0f, 255.0f, 0.0f, 100.0f) + (int)map(x, 0, 255, 30, 255), 30, 255);
Comment thread
softhack007 marked this conversation as resolved.
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);
Expand All @@ -3534,6 +3595,101 @@ CRGB AudioReactive::getCRGBForBand(int x, int pal) {
void AudioReactive::fillAudioPalettes() {
#ifndef _MoonModules_WLED_
if (!palettes) return;

// ToDO: Check if this functions needs a rate-limiter - it does not make sense to re-run the full math on every loop() iteration.
// (optionally) only re-run the function when new FFT results are availeable (sync with FFTTask)

// 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); // approximation
// float alpha = 1.0f - expf(-(float)dtMs / PALETTE_TIME_CONSTANT_MS); // (optional) exact value - better to preserve the time constant across loop cadences.

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
// ToDO: This fixed frequency table does not match all supported FFT mappings.
// With freqDist=1, channel 9 receives fftAddAvg(19,25) (about 950 Hz at 22.05 kHz), but this table weights it as 1658 Hz.
// Track Character therefore overestimates the spectral centroid and shifts the hue.
// Additionally, check if RMS bin averaging can move the central frequencies.
static const float bandFreq[NUM_GEQ_CHANNELS] = { // approximate centre frequency (Hz) of each GEQ channel - need rework to match the frequency mappings of WLED-MM
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
// ToDo: When a high-frequency signal fades to silence, crossing tEnergy == 1 switches the centroid from about 8 kHz to 500 Hz in a single refresh.
// The base hue jumps from about 199 to 86, while case 3's bright endpoint stays at brightness 255.
// Smoothing the band energies therefore does not prevent an abrupt color change at silence.
// --> Retain the last valid hue below the threshold, or smooth the transition to the fallback hue.
// --> filter/smooth transitions
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)my_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)my_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)my_mapf(maxRatio, 1.0f/3.0f, 1.0f, 180.0f, 255.0f), 180, 255);
paletteTotal4 = total;
}
// AI: end

processingPalettes = true; // lock palettes to ensure they don't get removed while processing
// 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) {
Expand Down Expand Up @@ -3585,3 +3741,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";
Loading