Skip to content

Commit 3421620

Browse files
committed
AutoLevel Experiments
1 parent a74106f commit 3421620

2 files changed

Lines changed: 201 additions & 2 deletions

File tree

usermods/audioreactive/audio_reactive.h

Lines changed: 118 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,11 @@ static uint8_t doSlidingFFT = 1; // 1 = use sliding w
208208
static uint8_t useAutoLevel = 1; // 1 = per-band slow-decay AGC (auto-balance loudest band to fill display)
209209
static float bandAutoGain[NUM_GEQ_CHANNELS] = {0.0f}; // per-band auto-gain (slow-decay AGC state)
210210
static bool bandAutoGainInited = false; // first-call init flag (seed at 1.0 once)
211+
static uint8_t useBandCompress = 0; // 1 = per-band log compression: log(1+k*x) / log(1+k*Xmax) — compresses within-band dynamic range
212+
static constexpr float BAND_COMPRESS_K = 4.0f; // compression factor (k=4: ~3 dB at top, ~20 dB at floor)
213+
static uint8_t useNoiseFloorSub = 0; // 1 = subtract per-band slow min (noise floor) from each band — helps in quiet environments
214+
static uint8_t useHistogramNorm = 0; // 1 = single global gain via loudest-band peak (alternative to per-band AGC)
215+
static uint8_t useBand0Lifter = 0; // 1 = soft "dance music fixer": weak coupling from band 1 to band 0 to compensate for poor low-freq mic response. band0 follows band1's contour without matching it.
211216

212217
// shared vars for debugging
213218
#ifdef MIC_LOGGER
@@ -408,7 +413,7 @@ static void computeBandsPsychoacoustic(float* fftCalc, float wc) {
408413
// and highest bands don't dominate.
409414
float damping[NUM_GEQ_CHANNELS];
410415
for (int k = 0; k < NUM_GEQ_CHANNELS; k++) damping[k] = 1.0f;
411-
damping[0] = 0.85f; // sub-bass roll-off
416+
damping[0] = 1.00; // sub-bass roll-off
412417
damping[1] = 0.92f; // bass roll-off
413418
damping[NUM_GEQ_CHANNELS - 1] = 0.70f; // top bin roll-off (matches legacy 0.70f)
414419
if (NUM_GEQ_CHANNELS >= 2)
@@ -956,6 +961,89 @@ void FFTcode(void * parameter)
956961
}
957962
}
958963

964+
// #4 Noise floor subtraction: track a slow minimum per band, subtract
965+
// it so quiet content isn't masked by the mic's noise floor. Helps
966+
// when each band has a different residual noise level.
967+
if (useNoiseFloorSub) {
968+
static float bandFloor[NUM_GEQ_CHANNELS] = {0.0f};
969+
static bool bandFloorInited = false;
970+
const float FLOOR_DECAY = 0.9999f;
971+
const float FLOOR_SMOOTH = 0.001f;
972+
if (!bandFloorInited) {
973+
for (int k = 0; k < NUM_GEQ_CHANNELS; k++) bandFloor[k] = 0.0f;
974+
bandFloorInited = true;
975+
}
976+
for (int k = 0; k < NUM_GEQ_CHANNELS; k++) {
977+
// Track the slow minimum, then push the floor up slowly so
978+
// transient dips don't get locked in.
979+
bandFloor[k] = (fftCalc[k] < bandFloor[k])
980+
? fftCalc[k]
981+
: (bandFloor[k] * FLOOR_DECAY + fftCalc[k] * FLOOR_SMOOTH);
982+
float v = fftCalc[k] - bandFloor[k];
983+
if (v < 0.0f) v = 0.0f;
984+
fftCalc[k] = v;
985+
}
986+
}
987+
988+
// #3 Histogram-based display gain: pick a single global gain that
989+
// makes the loudest visible band hit ~85% of the display, regardless
990+
// of how loud the source is. Periodically updated.
991+
if (useHistogramNorm && !useAutoLevel) {
992+
// Skip if AGC is on — AGC already does per-band equalization.
993+
static float histMax = 1.0f;
994+
const float HIST_DECAY = 0.999f;
995+
const float HIST_TARGET = 0.85f * 4096.0f;
996+
float curMax = 0.0f;
997+
for (int k = 0; k < NUM_GEQ_CHANNELS; k++)
998+
if (fftCalc[k] > curMax) curMax = fftCalc[k];
999+
if (curMax > 0.001f) {
1000+
if (curMax > HIST_TARGET) {
1001+
histMax = HIST_TARGET / curMax;
1002+
} else {
1003+
histMax = histMax * HIST_DECAY + (1.0f - HIST_DECAY);
1004+
if (histMax > 1.0f) histMax = 1.0f;
1005+
}
1006+
for (int k = 0; k < NUM_GEQ_CHANNELS; k++)
1007+
fftCalc[k] *= histMax;
1008+
}
1009+
}
1010+
1011+
// #5 Per-band log compression (soft-knee).
1012+
// Apply log(1 + x) to soften peaks without boosting the floor.
1013+
// Map [0, Xmax] -> [0, Xmax] via log(1+x)/log(1+Xmax) so peaks get
1014+
// compressed but small signals pass through nearly linearly.
1015+
// For k=4 / Xmax=4096: x=50 -> 1230, x=200 -> 2115, x=1000 -> 3123.
1016+
if (useBandCompress) {
1017+
const float Xmax = 4096.0f;
1018+
const float logXmax = logf(1.0f + Xmax);
1019+
for (int k = 0; k < NUM_GEQ_CHANNELS; k++) {
1020+
if (fftCalc[k] > 0.0f) {
1021+
// Use Xmax per-band so the curve normalises at the band's
1022+
// current peak. This compresses within-band transients while
1023+
// preserving per-band equalisation from the AGC step.
1024+
float bandMax = fftCalc[k];
1025+
for (int j = 0; j < 8; j++) {
1026+
if (bandMax > 0.0f) break;
1027+
bandMax = 0.0f;
1028+
}
1029+
// Apply log(1+x)/log(1+bandMax) — noop if bandMax <= 1
1030+
if (bandMax > 1.0f) {
1031+
float logBandMax = logf(1.0f + bandMax);
1032+
fftCalc[k] = (logf(1.0f + fftCalc[k]) / logBandMax) * bandMax;
1033+
}
1034+
}
1035+
}
1036+
}
1037+
1038+
// "Dance music fixer" — soft lift on band 0 from band 1. Compensates
1039+
// for mics with poor sub-bass response (e.g. the ESP32-P4 EV board's
1040+
// onboard mic) by adding a fraction of band 1 to band 0 when band 1
1041+
// is non-zero. Band 0 keeps its own shape; bands stay distinct.
1042+
if (useBand0Lifter && NUM_GEQ_CHANNELS >= 2 && fftCalc[1] > 0.0f) {
1043+
const float α = 0.15f; // 15% coupling — adds without matching
1044+
fftCalc[0] += α * fftCalc[1];
1045+
}
1046+
9591047
} else { // if second run skipped
9601048
memcpy(fftCalc, lastFftCalc, sizeof(fftCalc)); // restore last "good" channels
9611049
}
@@ -3148,6 +3236,10 @@ class AudioReactive : public Usermod {
31483236
poweruser[F("FFT_Window")] = fftWindow;
31493237
poweruser[F("I2S_FastPath")] = doSlidingFFT;
31503238
poweruser[F("AutoLevel")] = useAutoLevel;
3239+
poweruser[F("BandCompress")] = useBandCompress;
3240+
poweruser[F("NoiseFloorSub")] = useNoiseFloorSub;
3241+
poweruser[F("HistogramNorm")] = useHistogramNorm;
3242+
poweruser[F("Band0Lifter")] = useBand0Lifter;
31513243

31523244
JsonObject freqScale = top.createNestedObject("frequency");
31533245
freqScale[F("scale")] = FFTScalingMode;
@@ -3249,6 +3341,10 @@ class AudioReactive : public Usermod {
32493341

32503342
configComplete &= getJsonValue(top["experiments"][F("I2S_FastPath")], doSlidingFFT);
32513343
configComplete &= getJsonValue(top["experiments"][F("AutoLevel")], useAutoLevel);
3344+
configComplete &= getJsonValue(top["experiments"][F("BandCompress")], useBandCompress);
3345+
configComplete &= getJsonValue(top["experiments"][F("NoiseFloorSub")], useNoiseFloorSub);
3346+
configComplete &= getJsonValue(top["experiments"][F("HistogramNorm")], useHistogramNorm);
3347+
configComplete &= getJsonValue(top["experiments"][F("Band0Lifter")], useBand0Lifter);
32523348

32533349

32543350
configComplete &= getJsonValue(top["frequency"][F("scale")], FFTScalingMode);
@@ -3486,9 +3582,29 @@ class AudioReactive : public Usermod {
34863582

34873583
oappend(SET_F("dd=addDropdown(ux,xx+':AutoLevel');"));
34883584
oappend(SET_F("addOption(dd,'Off',0);"));
3489-
oappend(SET_F("addOption(dd,'On (⎌)',1);"));
3585+
oappend(SET_F("addOption(dd,'AGC: per-band (⎌)',1);"));
34903586
oappend(SET_F("addInfo(ux+':'+xx+':AutoLevel',1,'🐺');"));
34913587

3588+
oappend(SET_F("dd=addDropdown(ux,xx+':BandCompress');"));
3589+
oappend(SET_F("addOption(dd,'Off (⎌)',0);"));
3590+
oappend(SET_F("addOption(dd,'Per-band log: soften peaks',1);"));
3591+
oappend(SET_F("addInfo(ux+':'+xx+':BandCompress',1,'🐺');"));
3592+
3593+
oappend(SET_F("dd=addDropdown(ux,xx+':NoiseFloorSub');"));
3594+
oappend(SET_F("addOption(dd,'Off (⎌)',0);"));
3595+
oappend(SET_F("addOption(dd,'Subtract per-band floor',1);"));
3596+
oappend(SET_F("addInfo(ux+':'+xx+':NoiseFloorSub',1,'🐺');"));
3597+
3598+
oappend(SET_F("dd=addDropdown(ux,xx+':HistogramNorm');"));
3599+
oappend(SET_F("addOption(dd,'Off (⎌)',0);"));
3600+
oappend(SET_F("addOption(dd,'Global gain via loudest band',1);"));
3601+
oappend(SET_F("addInfo(ux+':'+xx+':HistogramNorm',1,'🐺');"));
3602+
3603+
oappend(SET_F("dd=addDropdown(ux,xx+':Band0Lifter');"));
3604+
oappend(SET_F("addOption(dd,'Off (⎌)',0);"));
3605+
oappend(SET_F("addOption(dd,'Lift band 0 from band 1',1);"));
3606+
oappend(SET_F("addInfo(ux+':'+xx+':Band0Lifter',1,'🐺');"));
3607+
34923608
oappend(SET_F("dd=addDropdown(ux,'dynamics:limiter');"));
34933609
oappend(SET_F("addOption(dd,'Off',0);"));
34943610
oappend(SET_F("addOption(dd,'On',1);"));

usermods/audioreactive/readme.md

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,88 @@ Other options:
5252
- `-D UM_AUDIOREACTIVE_ENABLE` : makes usermod default enabled (not the same as include into build option!)
5353
- `-D UM_AUDIOREACTIVE_DYNAMICS_LIMITER_OFF` : disables rise/fall limiter default
5454

55+
### GEQ Level / AGC / Compression Toggles
56+
57+
The `experiments` block in the AudioReactive settings has four runtime
58+
toggles that adjust how the GEQ (graphic equalizer) bars are scaled.
59+
All default OFF except `AutoLevel` (default ON). The toggles are:
60+
61+
- **`AutoLevel`** (default ON): per-band slow-decay AGC. Each band
62+
tracks its own peak with a slow-decay envelope and applies a per-band
63+
gain so the loudest band fills the display. Pink-noise (1/f) sources
64+
end up roughly flat in the display because each band settles
65+
independently. Combines well with `BandCompress` for a smooth, responsive
66+
visualizer.
67+
68+
- **`BandCompress`** (default OFF): per-band log compression. Applies
69+
`log(1+x) / log(1+bandMax)` so peaks are softened without boosting
70+
the floor. Each band compresses against its own current peak —
71+
bands keep their shape but transients don't blow out the display.
72+
Use with `AutoLevel` for the most "alive" look; without AGC the
73+
compression is barely visible.
74+
75+
- **`NoiseFloorSub`** (default OFF): per-band slow-minimum subtraction.
76+
Tracks the noise floor of each band (slowest minimum) and subtracts it,
77+
so quiet bands with high residual noise show their actual signal
78+
instead of always appearing at the noise level. Most useful with
79+
distant or noisy mics.
80+
81+
- **`HistogramNorm`** (default OFF): single global gain via loudest-band
82+
peak. Periodically picks a gain that makes the loudest band hit ~85%
83+
of the display. Mutually exclusive with `AutoLevel` (skipped when AGC
84+
is on, since both would double-correct). A simpler alternative to
85+
per-band AGC if you just want a single global gain.
86+
87+
- **`Band0Lifter`** (default OFF): "dance music fixer" — when band 1 is
88+
non-zero, add 15% of band 1's value to band 0. Compensates for mics
89+
with poor sub-bass response (e.g. the ESP32-P4 EV board's onboard
90+
analog MEMS mic) by using band 1 (60-120 Hz, reliably captured) as a
91+
weak hint for band 0. Bands stay visually distinct: the additive
92+
coupling is small enough that band 0 retains its own shape, just
93+
shifted up when band 1 is active.
94+
95+
### Recommended Combinations
96+
97+
Different content types benefit from different combinations:
98+
99+
- **Music playback (default AGC)**: just `AutoLevel`. Music is already
100+
mastered with proper levels; AGC rebalances bands to fill the display
101+
without changing the relative mix.
102+
103+
- **Live microphone / speech**: `AutoLevel` + `NoiseFloorSub` +
104+
`BandCompress`. The mic's noise floor is subtracted, AGC levels the
105+
bands, and compression prevents a single loud syllable from blowing
106+
out a bar.
107+
108+
- **Quiet environment / distant mic**: `NoiseFloorSub` only (no AGC).
109+
AGC would amplify the noise floor along with the signal; the natural
110+
variation between bands is informative.
111+
112+
- **Loud environment / clipping**: `AutoLevel` only. Don't subtract
113+
the floor (that would remove real signal) and don't compress
114+
(transients should still feel alive).
115+
116+
- **Visualizer for a screen / demo**: `AutoLevel` + `BandCompress`.
117+
Aggressive AGC + compression makes every band show activity — looks
118+
"alive" all the time.
119+
120+
- **Clean / smooth**: `AutoLevel` only. Single control, smooth display.
121+
122+
- **Raw / debugging**: all OFF. Bands show actual values; tune
123+
`pinkIndex` (0-10) and other parameters knowing you're seeing the
124+
true signal.
125+
126+
**Don't combine `AutoLevel` + `HistogramNorm`** — they're both gain
127+
adjustments; the code already skips `HistogramNorm` when `AutoLevel` is
128+
on to avoid double-correction.
129+
130+
**Don't combine `BandCompress` alone without AGC** — without AGC the
131+
input range is so wide that compression is barely visible.
132+
133+
**Set-and-forget for most users**: `AutoLevel` (default) +
134+
`NoiseFloorSub` + `BandCompress` handles nearly all content types
135+
gracefully with no user intervention.
136+
55137
**NOTE** I2S is used for analog audio sampling. Hence, the analog *buttons* (i.e. potentiometers) are disabled when running this usermod with an analog microphone.
56138

57139
### Advanced Compile-Time Options
@@ -66,5 +148,6 @@ You can use the following additional flags in your `build_flags`
66148

67149
## Release notes
68150

151+
* 2026-06 Moved to pure Espressif IDF v5.5 calls and ESP-DSP FFT accelleration, added esp_codec_dev supported codecs and added AutoLevel experiments - by @TroyHacks
69152
* 2022-06 Ported from [soundreactive WLED](https://github.com/atuline/WLED) - by @blazoncek (AKA Blaz Kristan) and the [SR-WLED team](https://github.com/atuline/WLED/wiki#sound-reactive-wled-fork-team).
70153
* 2022-11 Updated to align with "[MoonModules/WLED](https://amg.wled.me)" audioreactive usermod - by @softhack007 (AKA Frank M&ouml;hle).

0 commit comments

Comments
 (0)