FastLED 3.9.15
Loading...
Searching...
No Matches
sound_orchestrator.cpp
Go to the documentation of this file.
1// sound_orchestrator.cpp - implementation of the 3-state audio orchestrator.
3
4#include "fl/math/math.h"
5#include "fl/stl/chrono.h"
6
7namespace animartrix_ring {
8
9const char *toString(SoundState s) {
10 switch (s) {
11 case SoundState::Silence: return "Silence";
12 case SoundState::Disorganized: return "Disorganized";
13 case SoundState::BpmLocked: return "BpmLocked";
14 }
15 return "?";
16}
17
18namespace {
19
20// State -> visual bank lookup tables.
21// Per issue #2713 mapping:
22// Silence -> calm ambient (SLOW_FADE, WATER, PARAMETRIC_WATER, FLUFFY_BLOBS)
23// Disorganized -> energy/spectrum (RGB_BLOBS5, WAVES, POLAR_WAVES, COMPLEX_KALEIDO)
24// BpmLocked -> beat geometry (RINGS, CHASING_SPIRALS, SPIRALUS, CENTER_FIELD)
43
44// Cycle a bank slowly so a long-held state still has visual variety.
45constexpr fl::u32 kAnimCyclePeriodMs = 18000;
46
47template <typename Arr>
48fl::AnimartrixAnim pickFromBank(const Arr &bank, fl::u32 nowMs) {
49 const fl::u32 n = sizeof(bank) / sizeof(bank[0]);
50 const fl::u32 idx = (nowMs / kAnimCyclePeriodMs) % n;
51 return bank[idx];
52}
53
54} // namespace
55
61
63 if (!mProcessor) return;
64
65 // Capture event-style audio cues for BpmLocked state.
66 // The Processor stores a single callback per event; we install closures
67 // that mark "last seen at" timestamps which the per-frame tick consults.
68 // (We can't capture `this->mLastKickMs` directly by reference in a
69 // function<void()> stored by Processor across callback re-registration
70 // without UAF risk, so we route through `this`.)
71 SoundOrchestrator *self = this;
72 mProcessor->onKick([self]() {
73 self->mLastKickMs = fl::millis();
74 });
75 mProcessor->onSnare([self]() {
76 self->mLastSnareMs = fl::millis();
77 });
78 mProcessor->onDownbeat([self]() {
80 self->mDownbeatCount++;
81 });
82}
83
85 switch (s) {
86 case SoundState::Silence: return pickFromBank(kSilenceBank, nowMs);
87 case SoundState::Disorganized: return pickFromBank(kDisorganizedBank, nowMs);
88 case SoundState::BpmLocked: return pickFromBank(kBpmLockedBank, nowMs);
89 }
91}
92
93void SoundOrchestrator::switchAnimationIfNeeded(SoundState /*newState*/, fl::u32 nowMs) {
95 if (desired != mCurrentAnim && mAnimartrix) {
96 mCurrentAnim = desired;
97 mAnimartrix->fxSet(static_cast<int>(desired));
98 }
99}
100
102 if (!mProcessor) return SoundState::Silence;
103
104 const bool isSilent = mProcessor->isSilent();
105
106 // Silence has its own asymmetric hysteresis (enter slow, exit fast) so a
107 // single audio sample can wake us up promptly but a brief gap won't drop
108 // us into ambient mode mid-song.
109 if (isSilent) {
110 if (mSilentSinceMs == 0) mSilentSinceMs = nowMs;
112 } else {
113 if (mNonSilentSinceMs == 0) mNonSilentSinceMs = nowMs;
114 mSilentSinceMs = 0;
115 }
116
117 // --- raw signals ---
118 const float tempoConf = mProcessor->getTempoConfidence();
119 const float beatConf = mProcessor->getBeatConfidence();
120
121 // Silence-exit gate: when we're currently in Silence, require the
122 // configured silenceExitMs of *contiguous* non-silent audio before we
123 // even consider leaving. Without this, mNonSilentSinceMs would be
124 // tracked but never consulted, so a single non-silent frame would
125 // immediately bounce us out -- defeating the asymmetric silence
126 // hysteresis documented on OrchestratorConfig.
127 const bool silenceReleased =
128 !isSilent &&
129 mNonSilentSinceMs != 0 &&
130 (nowMs - mNonSilentSinceMs) >= mCfg.silenceExitMs;
131
132 // --- determine the candidate (instantaneous) state ---
133 SoundState instant;
134 if (isSilent && mSilentSinceMs && (nowMs - mSilentSinceMs) >= mCfg.silenceEnterMs) {
135 instant = SoundState::Silence;
136 } else if (mState == SoundState::Silence && !silenceReleased) {
137 // Hold Silence until the non-silent run reaches silenceExitMs.
138 instant = SoundState::Silence;
139 } else if (!isSilent &&
140 tempoConf >= mCfg.tempoConfidenceEnter &&
141 beatConf >= mCfg.beatConfidenceEnter) {
142 instant = SoundState::BpmLocked;
143 } else if (mState == SoundState::BpmLocked &&
144 (tempoConf >= mCfg.tempoConfidenceExit &&
145 beatConf >= mCfg.beatConfidenceExit)) {
146 // Stay locked: we're below "enter" but above "exit" thresholds.
147 instant = SoundState::BpmLocked;
148 } else if (mState == SoundState::Silence && isSilent) {
149 instant = SoundState::Silence;
150 } else {
151 instant = SoundState::Disorganized;
152 }
153
154 // --- hysteresis: candidate must hold for classifierHysteresisMs AND
155 // current state must have served minDwellMs before we accept the switch.
156 if (instant != mState) {
157 if (instant != mCandidate) {
158 mCandidate = instant;
159 mCandidateSinceMs = nowMs;
160 }
161 const fl::u32 candidateHeld = nowMs - mCandidateSinceMs;
162 const fl::u32 stateHeld = nowMs - mStateEnteredAtMs;
163 if (candidateHeld >= mCfg.classifierHysteresisMs &&
164 stateHeld >= mCfg.minDwellMs) {
165 return instant;
166 }
167 return mState; // not yet -- hold current state
168 }
169
170 // Candidate matches current state; reset candidate tracker.
172 mCandidateSinceMs = nowMs;
173 return mState;
174}
175
176float SoundOrchestrator::driveSilence(fl::u32 /*nowMs*/, float manualSpeedScalar) {
177 // Ambient: very slow, restrained. Time warp essentially off.
178 return mCfg.silenceSpeed * manualSpeedScalar;
179}
180
181float SoundOrchestrator::driveDisorganized(fl::u32 /*nowMs*/, float manualSpeedScalar) {
182 if (!mProcessor) return manualSpeedScalar;
183 // Map vibe.bass (self-normalizing, ~1.0 = average) into a speed modulation.
184 // This is the "time warp as secondary effect" knob: it still exists, but
185 // it's bounded by disorganizedSpeedSpan rather than dominating.
186 const float bass = mProcessor->getVibeBass(); // ~1.0 nominal
187 const float bassBoost = (bass - 1.0f) * mCfg.disorganizedSpeedSpan;
188 float speed = 1.0f + bassBoost;
189 if (speed < 0.1f) speed = 0.1f;
190 if (speed > 4.0f) speed = 4.0f;
191 return speed * manualSpeedScalar;
192}
193
194float SoundOrchestrator::driveBpmLocked(fl::u32 nowMs, float manualSpeedScalar) {
195 if (!mProcessor) return manualSpeedScalar;
196
197 // Baseline speed scales gently with BPM so faster songs read faster.
198 const float bpm = mProcessor->getBPM();
199 // Normalize BPM to a 0.6..1.6 multiplier around the nominal 120 BPM.
200 float bpmScale = 1.0f;
201 if (bpm > 1.0f) {
202 bpmScale = bpm / 120.0f;
203 if (bpmScale < 0.6f) bpmScale = 0.6f;
204 if (bpmScale > 1.6f) bpmScale = 1.6f;
205 }
206
207 // Pulse: kick/snare/downbeat events bump speed briefly and decay.
208 // Downbeats hit hardest (palette-level event); kicks medium; snares light.
209 auto pulseFromEvent = [&](fl::u32 t, float weight) -> float {
210 if (t == 0) return 0.0f;
211 const fl::u32 dt = nowMs - t;
212 if (dt >= mCfg.pulseDecayMs) return 0.0f;
213 const float k = 1.0f - (static_cast<float>(dt) / mCfg.pulseDecayMs);
214 return weight * k * k; // ease-out square
215 };
216 const float kickPulse = pulseFromEvent(mLastKickMs, 0.50f);
217 const float snarePulse = pulseFromEvent(mLastSnareMs, 0.25f);
218 const float downbeatPulse = pulseFromEvent(mLastDownbeatMs, 0.80f);
219 float pulse = kickPulse + snarePulse + downbeatPulse;
220 if (pulse > 1.5f) pulse = 1.5f;
221
222 // measurePhase smoothly fills the inter-beat gap so visuals breathe
223 // between pulses rather than freezing. Phase is 0..1 within the measure.
224 const float phase = mProcessor->getMeasurePhase(); // 0..1
225 // Sine bow so phase contributes gently throughout the measure.
226 const float phaseBow = 0.15f * fl::sin(phase * 6.2831853f);
227
228 float speed = mCfg.bpmLockedBaseSpeed * bpmScale + pulse + phaseBow;
229 if (speed < 0.2f) speed = 0.2f;
230 return speed * manualSpeedScalar;
231}
232
233float SoundOrchestrator::tick(fl::u32 nowMs, float manualSpeedScalar) {
234 if (mStateEnteredAtMs == 0) mStateEnteredAtMs = nowMs;
235
236 const SoundState newState = classify(nowMs);
237 if (newState != mState) {
238 mState = newState;
239 mStateEnteredAtMs = nowMs;
240 }
242
243 float speed;
244 switch (mState) {
245 case SoundState::Silence: speed = driveSilence(nowMs, manualSpeedScalar); break;
246 case SoundState::Disorganized: speed = driveDisorganized(nowMs, manualSpeedScalar); break;
247 case SoundState::BpmLocked: speed = driveBpmLocked(nowMs, manualSpeedScalar); break;
248 default: speed = manualSpeedScalar; break;
249 }
250
251 if (mEngine) mEngine->setSpeed(speed);
253 return speed;
254}
255
256} // namespace animartrix_ring
fl::Animartrix animartrix(xyMap, FIRST_ANIMATION)
void bpm()
static bool isSilent
uint16_t speed
Definition Noise.ino:66
FastLED chrono implementation - duration types for time measurements.
float tick(fl::u32 nowMs, float manualSpeedScalar)
Per-frame tick.
void begin()
Wire up audio callbacks (downbeat/kick/snare).
float driveBpmLocked(fl::u32 nowMs, float manualSpeedScalar)
static fl::AnimartrixAnim pickAnimationFor(SoundState s, fl::u32 nowMs)
SoundOrchestrator(fl::shared_ptr< fl::audio::Processor > processor, fl::shared_ptr< fl::Animartrix > animartrix, fl::FxEngine *engine)
void switchAnimationIfNeeded(SoundState newState, fl::u32 nowMs)
float driveSilence(fl::u32 nowMs, float manualSpeedScalar)
float driveDisorganized(fl::u32 nowMs, float manualSpeedScalar)
fl::shared_ptr< fl::audio::Processor > mProcessor
fl::shared_ptr< fl::Animartrix > mAnimartrix
Manages and renders multiple visual effects (Fx) for LED strips.
Definition fx_engine.h:33
static uint32_t t
Definition Luminova.h:55
fl::AnimartrixAnim pickFromBank(const Arr &bank, fl::u32 nowMs)
const char * toString(SoundState s)
fl::u32 millis()
Universal millisecond timer - returns milliseconds since system startup.
AnimartrixAnim
enable_if< is_fixed_point< T >::value, T >::type sin(T angle) FL_NOEXCEPT