Documentation Index
Fetch the complete documentation index at: https://mintlify.com/MrJefter/sdvx-controller/llms.txt
Use this file to discover all available pages before exploring further.
The SDVX Controller drives a strip of 12 WS2812B addressable RGB LEDs connected to PB0, updated at approximately 50 FPS via DMA-driven PWM through TIM1. The entire lighting system is implemented in visEffect.c and uses a three-layer compositing model: an animated ambient fog forms the background, a short white flash highlights button presses, and a coloured glow tracks encoder movement. All three layers are computed every 20 ms inside visScript() and written into a 36-byte RGB frame buffer before being handed off to the WS2812B DMA driver.
LED numbering and physical layout
LEDs are indexed 0–11 in the order they appear in the data chain. Their approximate positions in a 7 × 4 spatial grid (used by the noise animation) are:
led_coords[LED index] = {x, y}
LED 0: (0, 1) LED 1: (0, 2) LED 2: (0, 3)
LED 3: (2, 2) LED 4: (2, 1) LED 5: (2, 0)
LED 6: (4, 0) LED 7: (4, 1) LED 8: (4, 2)
LED 9: (6, 3) LED 10: (6, 2) LED 11: (6, 1)
The x/y coordinates are only used to generate spatially coherent noise for the fog layer — they do not represent physical millimetre positions.
frameBuffer stores each LED’s colour as three consecutive bytes in RGB order. The ws2812b library’s loadNextFramebufferData() reads them as (red, green, blue) and ws2812b_set_pixel() handles the GRB reordering required by the WS2812B wire protocol internally.
| Byte offset | Content |
|---|
i * 3 + 0 | Red |
i * 3 + 1 | Green |
i * 3 + 2 | Blue |
Total buffer size: 3 × 12 = 36 bytes.
Layer 1 — Ambient fog (background)
The base layer produces a slowly shifting purple haze. Every LED gets an independent brightness derived from a sum-of-three-sine-waves noise field evaluated at that LED’s grid coordinates:
float time_sec = (float)HAL_GetTick() * 0.0030f; // TIME_SCALE = 0.003
float noise_val = sinf(x * 0.5f + time_sec)
+ sinf(y * 0.6f + time_sec * 0.7f)
+ sinf((x + y) * 0.4f + time_sec * 1.3f);
// Normalise from [-3, 3] to [0, 1]
float noise_norm = (noise_val + 3.0f) / 6.0f;
The normalised noise value modulates brightness between 0.5 and 0.8:
float base_brightness = 0.5f + noise_norm * (0.8f - 0.5f);
The base fog color is RGB(150, 0, 255) — a deep purple. When the noise value exceeds 0.65, white is progressively mixed in at up to 50%, creating bright purple-white flickers in high-energy regions:
if (noise_norm > 0.65f) {
float white_mix = ((noise_norm - 0.65f) / (1.0f - 0.65f)) * 0.5f;
bg_r = r_fog * (1.0f - white_mix) + 255.0f * white_mix;
bg_g = g_fog * (1.0f - white_mix) + 255.0f * white_mix;
bg_b = b_fog * (1.0f - white_mix) + 255.0f * white_mix;
}
Every time a button transitions from released to pressed (rising edge detected by comparing the current joystickReport.buttons byte against the previous frame’s state), a 250 ms white flash is triggered on the one or two LEDs mapped to that button.
The pulse linearly fades from full brightness at the moment of press to zero at 250 ms:
pulse_intensity = 1.0f - ((float)elapsed_ms / 250.0f);
r_final = 255.0f * pulse_intensity; // white
g_final = 255.0f * pulse_intensity;
b_final = 255.0f * pulse_intensity;
While a pulse is active it completely overrides the fog background for that LED.
| Button | LED indices triggered |
|---|
| BT-A (BTN1) | 0, 1 |
| BT-B (BTN2) | 0, 3 |
| BT-C (BTN3) | 8, 11 |
| BT-D (BTN4) | 11, 10 |
| FX-L | 1, 2 |
| FX-R | 10, 9 |
| START | 5, 6 |
Layer 3 — Encoder activity glow
Four LEDs per encoder light up in a solid colour whenever that encoder is spinning. This layer is additive — it is added on top of whichever of the two layers below it is currently showing.
LED assignments
| Encoder | LED indices | Glow colour |
|---|
| VOL-L (Enc 1) | 2, 3, 4, 5 | Cyan — RGB(0, 255, 255) |
| VOL-R (Enc 2) | 6, 7, 8, 9 | Magenta — RGB(255, 0, 255) |
Activity model
Each encoder maintains a float activity value (range 0.0 – 1.0):
- Attack (instant): When the absolute encoder delta for a frame exceeds the current activity, the activity is immediately set to
abs(delta) / 50.0, clamped to 1.0.
- Decay: Each frame the activity is multiplied by 0.955, producing a smooth exponential decay. Values below 0.01 snap to 0.0.
The brightness contribution is:
float intensity = encoder_activity * 2.0f; // ENCODER_INTENSITY_SCALE
if (intensity > 1.0f) intensity = 1.0f; // clamp
float brightness_scale = intensity * 200.0f; // ENCODER_VIS_MAX_BRIGHTNESS
r_final += COLOR_R * brightness_scale / 255.0f;
g_final += COLOR_G * brightness_scale / 255.0f;
b_final += COLOR_B * brightness_scale / 255.0f;
Maximum brightness contribution from the encoder layer is 200 / 255 ≈ 78% of the colour’s full value.
Update rate and DMA transfer
visHandle() is called every iteration of the 1 ms main loop. It checks whether the previous DMA transfer to the LED strip is complete (ws2812b.transferComplete), and if so calls visHandle2(), which applies a 20 ms gate:
const uint32_t update_interval_ms = 20; // ~50 FPS
if ((HAL_GetTick() - timestamp) >= update_interval_ms) {
visScript(frameBuffer, sizeof(frameBuffer));
}
Once visScript() writes the new frame into frameBuffer, ws2812b_handle() initiates a DMA transfer via TIM1 PWM on PB0 to clock the GRB data out to the LED strip.