FastLED 3.9.15
Loading...
Searching...
No Matches
vibe.cpp.hpp
Go to the documentation of this file.
1// Vibe - Self-normalizing audio analysis
2//
3// Algorithm ported from MilkDrop v2.25c by Ryan Geiss.
4// Three-stage processing: immediate fft::FFT → asymmetric EMA → slow EMA normalization.
5//
6// Uses 3 LINEAR fft::FFT bins that map directly to bass/mid/treb bands.
7// Linear bins at 20-11025 Hz with 3 bins gives ~3668 Hz per bin:
8// bin 0: 20-3688 Hz (bass), bin 1: 3688-7356 Hz (mid), bin 2: 7356-11025 Hz (treb)
9
12#include "fl/math/math.h"
13#include "fl/stl/noexcept.h"
14
15namespace fl {
16namespace audio {
17namespace detector {
18
19static int sVibeFFTCount = 0;
22
23
25 // Tau chosen for LED visualizer feel: ~0.3s gives ~1 second of silence
26 // to fully gate, matching user expectation that beats decay quickly when
27 // music stops. The envelope targets 0.0 — full silence on the metric.
29 cfg.decayTauSeconds = 0.3f;
30 cfg.targetValue = 0.0f;
31 for (int i = 0; i < 3; ++i) {
32 mImmRelEnv[i].configure(cfg);
33 mAvgRelEnv[i].configure(cfg);
34 // Seed cached "last audio" value at 1.0 to match the detector's
35 // initial public state (getBass()/getMid()/getTreb() return 1.0
36 // before any update()). Without this, the first silent update
37 // would immediately decay from 0 — no visible change, but semantic.
38 mImmRelEnv[i].reset(1.0f);
39 mAvgRelEnv[i].reset(1.0f);
40 }
41}
42
43Vibe::~Vibe() FL_NOEXCEPT = default;
44
45void Vibe::update(shared_ptr<Context> context) {
46 if (!context) {
47 return;
48 }
49
51 mSampleRate = context->getSampleRate();
52
53 // --- Step 1: Get 3-band energy from shared context ---
54 BandEnergy energy = context->getBandEnergy();
56
57 span<const i16> pcm = context->getPCM();
58
59 // --- Step 2: Read bass/mid/treb directly ---
60 mImm[0] = energy.bass;
61 mImm[1] = energy.mid;
62 mImm[2] = energy.treb;
63
64 // --- Step 3: Temporal blending (MilkDrop v2.25c algorithm) ---
65 // Compute effective FPS from audio buffer duration
66 float dt = computeAudioDt(pcm.size(), mSampleRate);
67 float actualFps = (dt > 0.0f) ? (1.0f / dt) : mTargetFps;
68
69 if (mFrameCount == 1) {
70 // MilkDrop first-frame initialization: set averages directly
71 // Prevents false spikes and startup transients
72 for (int i = 0; i < 3; i++) {
73 mAvg[i] = mImm[i];
74 mLongAvg[i] = mImm[i];
75 }
76 } else {
77 // Pre-compute FPS-adjusted rates (avoids redundant powf in loop)
78 float attackRate = adjustRateToFPS(0.2f, 30.0f, actualFps);
79 float decayRate = adjustRateToFPS(0.5f, 30.0f, actualFps);
80 float longRate = adjustRateToFPS(0.992f, 30.0f, actualFps);
81
82 for (int i = 0; i < 3; i++) {
83 // Short-term average: asymmetric attack/decay
84 // Fast attack (rate=0.2 at 30fps → ~80% new signal on beats)
85 // Slow decay (rate=0.5 at 30fps → graceful fadeout)
86 float rate = (mImm[i] > mAvg[i]) ? attackRate : decayRate;
87 mAvg[i] = mAvg[i] * rate + mImm[i] * (1.0f - rate);
88
89 // Long-term average: slow symmetric EMA (MilkDrop v2.25c algorithm)
90 // Rate = 0.992 at 30fps → tau ≈ 4.2 seconds
91 // Tracks the overall energy level for self-normalization.
92 // Unlike a running maximum, this centers relative levels around 1.0,
93 // giving beats proper excursions above 1.0 and quiet sections below 1.0.
94 mLongAvg[i] = mLongAvg[i] * longRate + mAvg[i] * (1.0f - longRate);
95 }
96 }
97
98 // --- Step 4: Self-normalizing relative levels ---
99 // Division by long-term average makes levels independent of volume/genre.
100 // Values hover around 1.0; >1 means louder than average, <1 means quieter.
101 for (int i = 0; i < 3; i++) {
102 if (mLongAvg[i] < 0.001f) {
103 mImmRel[i] = 1.0f;
104 mAvgRel[i] = 1.0f;
105 } else {
106 mImmRel[i] = mImm[i] / mLongAvg[i];
107 mAvgRel[i] = mAvg[i] / mLongAvg[i];
108 }
109 }
110
111 // --- Step 4b: Silence gate ---
112 // MilkDrop's self-normalization drives mImmRel toward 1.0 when input is
113 // silent (noise / noise → 1.0), and the < 0.001f clamp above also pins
114 // to 1.0. Neither is what LED effects want when music stops. Route each
115 // band through its SilenceEnvelope: pass-through during audio, exponential
116 // decay toward 0 during silence. The Context::isSilent() flag is populated
117 // by Processor/Reactive from NoiseFloorTracker. Re-uses dt computed above.
118 const bool silent = context->isSilent();
119 for (int i = 0; i < 3; i++) {
120 mImmRel[i] = mImmRelEnv[i].update(silent, mImmRel[i], dt);
121 mAvgRel[i] = mAvgRelEnv[i].update(silent, mAvgRel[i], dt);
122 }
123
124 // --- Step 5: Spike detection ---
125 // When immediate exceeds smoothed, energy is rising — a beat is in progress.
129
130 mBassSpike = mImmRel[0] > mAvgRel[0];
131 mMidSpike = mImmRel[1] > mAvgRel[1];
132 mTrebSpike = mImmRel[2] > mAvgRel[2];
133}
134
136 if (onVibeLevels) {
137 VibeLevels levels;
138 levels.bass = mImmRel[0];
139 levels.mid = mImmRel[1];
140 levels.treb = mImmRel[2];
141 levels.vol = (mImmRel[0] + mImmRel[1] + mImmRel[2]) / 3.0f;
142 levels.bassSpike = mBassSpike;
143 levels.midSpike = mMidSpike;
144 levels.trebSpike = mTrebSpike;
145 levels.bassRaw = mImm[0];
146 levels.midRaw = mImm[1];
147 levels.trebRaw = mImm[2];
148 levels.bassAvg = mAvg[0];
149 levels.midAvg = mAvg[1];
150 levels.trebAvg = mAvg[2];
151 levels.bassLongAvg = mLongAvg[0];
152 levels.midLongAvg = mLongAvg[1];
153 levels.trebLongAvg = mLongAvg[2];
154 onVibeLevels(levels);
155 }
156
157 // Fire spike callbacks on rising edge (transition from no-spike to spike)
159 onBassSpike();
160 }
162 onMidSpike();
163 }
165 onTrebSpike();
166 }
167}
168
170 mFrameCount = 0;
171 for (int i = 0; i < 3; i++) {
172 mImm[i] = 0.0f;
173 mAvg[i] = 0.0f;
174 mLongAvg[i] = 0.0f;
175 mImmRel[i] = 1.0f;
176 mAvgRel[i] = 1.0f;
177 // Match the mImmRel/mAvgRel defaults of 1.0 so the silence gate
178 // picks up from the public state after reset().
179 mImmRelEnv[i].reset(1.0f);
180 mAvgRelEnv[i].reset(1.0f);
181 }
182 mBassSpike = false;
183 mMidSpike = false;
184 mTrebSpike = false;
185 mPrevBassSpike = false;
186 mPrevMidSpike = false;
187 mPrevTrebSpike = false;
188}
189
190// FPS-independent rate adjustment:
191// Converts a per-frame smoothing rate tuned at fps1 to the equivalent rate
192// at actualFps, preserving the per-second behavior.
193//
194// At 30fps with rate=0.5: per-second retention = 0.5^30 ≈ 9.3e-10
195// At 60fps the per-frame rate adjusts to ~0.707 so 0.707^60 ≈ 9.3e-10 (same)
196float Vibe::adjustRateToFPS(float rateAtFps1, float fps1,
197 float actualFps) {
198 if (actualFps <= 0.0f) {
199 return rateAtFps1;
200 }
201 float perSecond = fl::powf(rateAtFps1, fps1);
202 return fl::powf(perSecond, 1.0f / actualFps);
203}
204
205} // namespace detector
206} // namespace audio
207} // namespace fl
static void resetPrivateFFTCount() FL_NOEXCEPT
Definition vibe.cpp.hpp:21
void reset() FL_NOEXCEPT override
Definition vibe.cpp.hpp:169
function_list< void(const VibeLevels &)> onVibeLevels
Definition vibe.h:128
void fireCallbacks() FL_NOEXCEPT override
Definition vibe.cpp.hpp:135
void update(shared_ptr< Context > context) FL_NOEXCEPT override
Definition vibe.cpp.hpp:45
SilenceEnvelope mImmRelEnv[3]
Definition vibe.h:170
function_list< void()> onMidSpike
Definition vibe.h:131
function_list< void()> onBassSpike
Definition vibe.h:130
static float adjustRateToFPS(float rateAtFps1, float fps1, float actualFps) FL_NOEXCEPT
Definition vibe.cpp.hpp:196
~Vibe() FL_NOEXCEPT override
SilenceEnvelope mAvgRelEnv[3]
Definition vibe.h:171
static int getPrivateFFTCount() FL_NOEXCEPT
Definition vibe.cpp.hpp:20
function_list< void()> onTrebSpike
Definition vibe.h:132
constexpr fl::size size() const FL_NOEXCEPT
Definition span.h:458
static int sVibeFFTCount
Definition vibe.cpp.hpp:19
float computeAudioDt(fl::size pcmSize, int sampleRate) FL_NOEXCEPT
Compute the time delta (in seconds) for an audio buffer.
float powf(float base, float exponent) FL_NOEXCEPT
Definition math.h:436
Base definition for an LED controller.
Definition crgb.hpp:179
#define FL_NOEXCEPT