Control Research: Fire/Wave Modulation Algorithms
PWM frequency and gamma-correction notes plus pseudocode sketches for fire/wave-like brightness modulation, and what the knob could map to.
Two frequencies, not one
It helps to separate two very different rates in this design:
PWM carrier frequency — how fast the MCU's hardware timer toggles the dim output on/off to encode an instantaneous duty cycle. This must be fast enough to be invisible and (if it interacts with the driver's control loop rather than a simple switch) within the driver's acceptable dim-input range.
Animation update rate — how often the software computes a new target brightness value (the "frame rate" of the fire/wave effect itself). This is far slower — tens of Hz is plenty for something that reads as smooth, organic motion to a human eye.
The hardware timer handles (1) continuously in the background; firmware only needs to update the timer's compare/duty register at rate (2).
PWM carrier frequency choice
Human flicker perception: direct (foveal) vision generally stops perceiving flicker above ~100-120Hz, but peripheral vision is more sensitive and can catch flicker up to ~300Hz, and quick eye or object motion ("phantom array" effect) can reveal flicker at frequencies well above the classic thresholds. A slow, ambient decorative lamp is mostly viewed in peripheral vision and may be seen in motion (camera pans, walking past it), so targeting ≥500Hz-1kHz for the PWM carrier gives real margin over the ~100-300Hz flicker-perception floor, rather than just clearing the minimum bar.
A second, independent constraint is audible noise: if the PWM signal ever couples into a magnetic component (e.g., if the "dim" pin modulates something inside the driver's own switching loop rather than a clean logic-level enable), frequencies in the audio band (roughly 20Hz-20kHz, worst around 2-8kHz where hearing is most sensitive) can produce an audible whine. Staying in the 1-2kHz range is a reasonable middle ground: comfortably above the flicker-perception floor, while low enough that most people won't localize it as an obvious high-pitched noise even if some coupling exists — though above 20kHz is the only way to guarantee inaudibility if the driver topology makes that a real risk. All three MCU candidates in mcu-candidates can generate PWM well past 20kHz on a general-purpose timer if that turns out to matter.
Interaction with the driver's dim-input bandwidth: the LED driver research (owned by the sibling driver-* pages) will specify whether its dim pin expects (a) a direct duty-cycle PWM signal that the driver's own internal circuitry decodes, or (b) an analog control voltage (0 to some V_ref) that must be produced by low-pass filtering the MCU's PWM output through an external RC filter to emulate a DAC. Case (b) adds a real constraint: the RC filter's cutoff must sit well below the PWM carrier frequency (to smooth it into a clean DC-like level) but well above the animation update rate (so the filter doesn't itself introduce lag into the fire/wave motion) — e.g., a carrier at 1kHz and an animation update at 50Hz leaves a full decade of room for an RC filter around 100-200Hz. This is called out explicitly as a question for the architecture pass below, since the driver choice isn't finalized yet.
Gamma / perceptual correction
Human brightness perception is roughly logarithmic, not linear with PWM duty cycle — a linear duty-cycle sweep looks bunched up and barely-changing at the bright end, then jumps rapidly near zero. To get "50% perceived brightness," the actual duty cycle needs to be roughly 18%, not 50% — this is well-documented in LED dimming references using either a gamma curve (commonly y = x^2.2 to x^2.8) or the more precise CIE 1931 lightness formula. Concretely, the animation algorithm should work in a perceptual brightness space (0-255 "how bright it should look") and pass every value through a gamma lookup table before writing it to the PWM duty register — this is what makes "lighter-darker, like flowing" read as smooth rather than jerky, especially at the low-brightness end where a fire/candle effect spends a lot of its time.
// 256-entry gamma LUT, computed once at build time (or startup):
// lut[i] = round(255 * pow(i / 255.0, GAMMA)) // GAMMA ~ 2.2-2.8
uint8_t gamma_lut[256];
void set_perceptual_brightness(uint8_t perceptual_0_255) {
uint8_t duty = gamma_lut[perceptual_0_255];
pwm_set_duty(duty); // writes the hardware timer compare register
}On a tiny MCU with 16-20KB flash, a 256-byte uint8_t LUT is cheap; if flash is tighter, an 8-bit approximation (x*x/255 for a rough gamma≈2) is a workable fallback with a small quality loss.
Modulation approach 1 — Filtered noise ("candle flicker")
The classic approach used in commercial candle-flicker LEDs and countless firmware candle projects: take pseudo-random noise and pass it through a low-pass filter (a single-pole IIR is enough) so it wanders smoothly instead of jumping erratically, then add it as a delta around a base brightness.
// Runs at the animation update rate (e.g. 50-100Hz)
int16_t noise_state = 0;
uint8_t next_flicker_frame(uint8_t base_brightness, uint8_t depth, uint8_t responsiveness /*0-255*/) {
int16_t raw_noise = (int16_t)random_uniform(-128, 127);
// one-pole low-pass: state += (raw - state) * alpha; alpha = responsiveness/256
noise_state += ((raw_noise - noise_state) * responsiveness) >> 8;
int16_t value = base_brightness + ((noise_state * depth) >> 8);
return clamp(value, 0, 255);
}responsiveness is the IIR filter's alpha: a high value lets the state track the raw random noise almost immediately (twitchy, jittery flicker), while a low value heavily filters it into slow, lazy motion ("breathing" rather than "flickering"). If the knob-mapping (see below) wants a "speed" control that behaves the intuitive way — turning it up makes the effect more active — map the knob to responsiveness directly; if a "smoothness" framing is preferred instead, invert it (alpha = 255 - knob_value) before passing it in. Layering 2-3 of these at different responsiveness/depth settings and summing them (as used in more elaborate candle-emulation firmware) adds realism at the cost of a bit more CPU/RAM — worth prototyping both a single-layer and layered version.
Modulation approach 2 — Bounded random walk ("slow wave / breathing")
A slower, smoother variant for a "wave" rather than "fire" feel: the brightness target takes small random steps and reflects off configured min/max bounds, rather than being pulled toward a fixed base value. This produces a more organic, less centered drift than the filtered-noise approach.
// Runs at the animation update rate
int16_t wave_value = 128; // current perceptual brightness, 0-255
int8_t wave_dir = 1; // +1 or -1, flips on bounds or randomly
uint8_t next_wave_frame(uint8_t min_b, uint8_t max_b, uint8_t step_size, uint8_t wander_chance) {
if (random_uniform(0, 255) < wander_chance) {
wave_dir = -wave_dir; // occasionally reverse direction unprompted
}
wave_value += wave_dir * random_uniform(1, step_size);
if (wave_value <= min_b) { wave_value = min_b; wave_dir = 1; }
if (wave_value >= max_b) { wave_value = max_b; wave_dir = -1; }
return (uint8_t)wave_value;
}step_size maps naturally to "speed," (max_b - min_b) maps naturally to "depth."
Modulation approach 3 — LUT playback of recorded flame data
Instead of synthesizing randomness, record a real flame's luminance curve (from video analysis or a reference dataset) as a lookup table baked into flash, and play it back in a loop — optionally with small per-loop randomization (slight speed jitter, randomized start offset each loop, occasional skip-ahead) so the repeat isn't perceptible.
extern const uint8_t flame_lut[LUT_LEN]; // pre-baked in flash
uint16_t play_pos = 0;
uint16_t loop_speed_q8 = 256; // 256 = 1.0x speed, knob-adjustable
uint8_t next_lut_frame(void) {
play_pos = (play_pos + (loop_speed_q8 >> 8)) % LUT_LEN;
// add sub-pixel interpolation between play_pos and play_pos+1 for smoother motion
// at fractional loop_speed_q8 values, or fixed-point accumulate for smooth speed control
return flame_lut[play_pos];
}This trades RAM/flash (the LUT itself — realistically a few hundred bytes to a couple KB depending on resolution/length) for the most "authentically fire-like" motion, since it's literally sampled from real flame behavior rather than approximated. Best fit for the higher-flash STM32G031 candidate from mcu-candidates if the LUT needs to be large; CH32V003/PY32's smaller flash budgets favor a short, tightly-compressed LUT or one of the procedural approaches instead.
All three approaches write into the same set_perceptual_brightness() gamma-corrected output path described above — the choice between them is about how the target value is generated, not how it reaches the PWM pin.
What the knob could map to (enumerate — architecture pass decides)
A single knob (rotary encoder per control-knob) can only directly control one parameter at a time unless combined with a mode gesture (e.g., short-press-to-cycle if the encoder's push-button variant is used). Candidates, any of which fit the pseudocode above:
Base brightness — the DC level the flicker/wave varies around (
base_brightnessin approach 1, or the(min_b+max_b)/2center in approach 2). Most intuitive "lamp knob" behavior for a first-time user.Speed — how fast the effect moves (
responsivenessin approach 1,step_size/wander_chancein approach 2,loop_speed_q8in approach 3). Turns a calm ember into an agitated flame.Depth — how much the brightness swings around its center (
depthin approach 1,(max_b - min_b)in approach 2). Turns a subtle flicker into a dramatic one.Mode-cycled combination — if the encoder has an integrated push-button, a short press could cycle which of the above the knob currently adjusts, letting one physical control reach all three parameters at the cost of a small UI-discoverability trade-off (no visual indicator of current mode without an added status LED or similar).
Questions the architecture pass must answer
Which single parameter (or mode-cycled set) does the knob control? This is explicitly left open per the source issue's scope.
Which of the three modulation approaches (or a hybrid/layered combination) does the firmware implement? Approach 1 and 2 are cheap and procedural; approach 3 needs a real recorded/authored dataset before flash size can be finalized.
What does the LED driver's dim pin actually expect (direct PWM vs. filtered analog voltage) — this determines whether an external RC filter is needed and what its cutoff should be, per the "interaction with the driver's dim-input bandwidth" note above. Depends on the driver choice from the sibling power-stage research.
Final PWM carrier frequency and animation update rate — the ranges above (~1-2kHz carrier, ~30-100Hz animation update) are a reasonable starting point but should be tuned once the physical LED/diffuser is chosen (a diffused/frosted lamp may hide flicker more than a bare LED).
Sources consulted for the flicker-threshold and gamma-correction claims above: Waveform Lighting — flicker-free LED dimming, Analog Devices — avoiding the audio band with PWM LED dimming, mbedded.ninja — controlling LED brightness with PWM (gamma correction), Lucky Resistor — candlelight emulation with layered randomness.