Skip to main content

midi_controller/
led.rs

1//! LED ring renderer: spatial patterns + temporal modifiers.
2//!
3//! Design: the SK6812 LEDs handle brightness natively via RGB values.
4//! The renderer produces a 12-pixel frame, then the modifier attenuates it per tick.
5
6use serde::{Deserialize, Serialize};
7
8pub const LEDS_PER_RING: usize = 12;
9pub type RingFrame = [Rgb; LEDS_PER_RING];
10
11/// RGB color (matches smart_leds::RGB8 layout).
12#[derive(Debug, Copy, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
13pub struct Rgb {
14    pub r: u8,
15    pub g: u8,
16    pub b: u8,
17}
18
19impl Rgb {
20    pub const fn new(r: u8, g: u8, b: u8) -> Self {
21        Self { r, g, b }
22    }
23
24    pub const BLACK: Self = Self::new(0, 0, 0);
25
26    /// Scale brightness by factor 0..=255 (255 = full).
27    pub fn scale(self, factor: u8) -> Self {
28        Self {
29            r: ((self.r as u16 * factor as u16) / 255) as u8,
30            g: ((self.g as u16 * factor as u16) / 255) as u8,
31            b: ((self.b as u16 * factor as u16) / 255) as u8,
32        }
33    }
34}
35
36/// Spatial pattern — which LEDs are lit and what color.
37#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
38pub enum Renderer {
39    Off,
40    /// All 12 LEDs same color.
41    Solid(Rgb),
42    /// Arc from rotation anchor, `count` LEDs lit (0–12).
43    Fill(Rgb, u8),
44    /// Potentiometer arc (7h→5h), blue→green→red. `fill` = 0–12.
45    Heatmap(u8),
46    /// Single LED at clock position (0–11).
47    Single(Rgb, u8),
48    /// N evenly-spaced LEDs (1,2,3,4,6,12).
49    Dots(Rgb, u8),
50}
51
52/// Temporal modifier — how rendered pixels change over time.
53#[derive(Debug, Copy, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
54pub enum Modifier {
55    /// No modulation, full brightness.
56    #[default]
57    Solid,
58    /// Static dim (1/4 intensity) — "available but inactive".
59    Glow,
60    /// On/off toggle at ~4Hz.
61    Blink,
62    /// Sine-wave fade, period ~1.5s.
63    Pulse,
64    /// Rotate pattern clockwise, one step per ~100ms.
65    Rotate,
66    /// Cycle hue over time (ignores original color, keeps lit/unlit pattern).
67    ColorCycle,
68}
69
70/// Complete ring animation state.
71#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
72pub struct RingAnimation {
73    pub renderer: Renderer,
74    pub modifier: Modifier,
75}
76
77impl Default for RingAnimation {
78    fn default() -> Self {
79        Self {
80            renderer: Renderer::Off,
81            modifier: Modifier::Solid,
82        }
83    }
84}
85
86impl RingAnimation {
87    pub const fn off() -> Self {
88        Self {
89            renderer: Renderer::Off,
90            modifier: Modifier::Solid,
91        }
92    }
93
94    pub const fn solid(color: Rgb) -> Self {
95        Self {
96            renderer: Renderer::Solid(color),
97            modifier: Modifier::Solid,
98        }
99    }
100
101    pub const fn glow(color: Rgb) -> Self {
102        Self {
103            renderer: Renderer::Solid(color),
104            modifier: Modifier::Glow,
105        }
106    }
107}
108
109/// Stateful ring that tracks animation state.
110#[derive(Copy, Clone)]
111pub struct LedRing {
112    pub animation: RingAnimation,
113    rotation: u8,
114}
115
116impl LedRing {
117    pub const fn new(rotation: u8) -> Self {
118        Self {
119            animation: RingAnimation {
120                renderer: Renderer::Off,
121                modifier: Modifier::Solid,
122            },
123            rotation,
124        }
125    }
126
127    pub fn set(&mut self, anim: RingAnimation) {
128        self.animation = anim;
129    }
130
131    /// Render current frame using a global tick (shared across all rings).
132    pub fn render(&self, tick: u16) -> RingFrame {
133        let base = self.render_spatial();
134        self.apply_modifier(base, tick)
135    }
136
137    fn render_spatial(&self) -> RingFrame {
138        match self.animation.renderer {
139            Renderer::Off => [Rgb::BLACK; LEDS_PER_RING],
140            Renderer::Solid(c) => [c; LEDS_PER_RING],
141            Renderer::Fill(c, count) => {
142                let mut frame = [Rgb::BLACK; LEDS_PER_RING];
143                for i in 0..(count as usize).min(LEDS_PER_RING) {
144                    frame[(self.rotation as usize + LEDS_PER_RING - i) % LEDS_PER_RING] = c;
145                }
146                frame
147            }
148            Renderer::Heatmap(fill) => {
149                const ARC_HOURS: [usize; 11] = [7, 8, 9, 10, 11, 0, 1, 2, 3, 4, 5];
150                let mut frame = [Rgb::BLACK; LEDS_PER_RING];
151                let lit = ((fill as usize) * ARC_HOURS.len() / 12).min(ARC_HOURS.len());
152                for i in 0..lit {
153                    frame[CLOCK[ARC_HOURS[i]]] = heatmap_color(i, ARC_HOURS.len());
154                }
155                frame
156            }
157            Renderer::Single(c, pos) => {
158                let mut frame = [Rgb::BLACK; LEDS_PER_RING];
159                let p = (pos as usize) % 12;
160                frame[CLOCK[p]] = c;
161                frame[CLOCK[(p + 11) % 12]] = c; // neighbor left
162                frame[CLOCK[(p + 1) % 12]] = c; // neighbor right
163                frame
164            }
165            Renderer::Dots(c, count) => {
166                let mut frame = [Rgb::BLACK; LEDS_PER_RING];
167                let n = (count as usize).clamp(1, 12);
168                let spacing = LEDS_PER_RING / n;
169                for i in 0..n {
170                    frame[CLOCK[(i * spacing) % 12]] = c;
171                }
172                frame
173            }
174        }
175    }
176
177    fn apply_modifier(&self, mut frame: RingFrame, tick: u16) -> RingFrame {
178        match self.animation.modifier {
179            Modifier::Solid => frame,
180            Modifier::Glow => {
181                for px in frame.iter_mut() {
182                    *px = px.scale(32); // 1/8 brightness, preserves spatial pattern
183                }
184                frame
185            }
186            Modifier::Blink => {
187                // Toggle every 25 ticks (0.5s at 50Hz, or 1 beat at 24ppqn BPM)
188                if (tick / 25) % 2 == 1 {
189                    [Rgb::BLACK; LEDS_PER_RING]
190                } else {
191                    frame
192                }
193            }
194            Modifier::Pulse => {
195                // Smooth version of blink: bright during ON phase, dark during OFF
196                // Same boundaries as blink: period 50, first half = bright, second = dark
197                let pos = tick % 50;
198                let factor = sine_u8(pos, 50);
199                for px in &mut frame {
200                    *px = px.scale(factor);
201                }
202                frame
203            }
204            Modifier::Rotate => {
205                // One step every 25 ticks (0.5s, or 1 beat)
206                let shift = (tick / 25) as usize % LEDS_PER_RING;
207                let mut rotated = [Rgb::BLACK; LEDS_PER_RING];
208                for i in 0..LEDS_PER_RING {
209                    rotated[(i + shift) % LEDS_PER_RING] = frame[i];
210                }
211                rotated
212            }
213            Modifier::ColorCycle => {
214                let hue = (tick * 3) as u8;
215                let color = hue_to_rgb(hue);
216                for px in frame.iter_mut() {
217                    if *px != Rgb::BLACK {
218                        *px = color;
219                    }
220                }
221                frame
222            }
223        }
224    }
225}
226
227impl Default for LedRing {
228    fn default() -> Self {
229        Self::new(8)
230    }
231}
232
233// --- Lookup tables ---
234
235/// Physical LED index for each clock-hour position.
236/// PCB layout: D1=3h, D2=2h, ..., D12=4h. 0-based.
237const CLOCK: [usize; 12] = [
238    3,  //  0: 12h
239    2,  //  1:  1h
240    1,  //  2:  2h
241    0,  //  3:  3h
242    11, //  4:  4h
243    10, //  5:  5h
244    9,  //  6:  6h
245    8,  //  7:  7h
246    7,  //  8:  8h
247    6,  //  9:  9h
248    5,  // 10: 10h
249    4,  // 11: 11h
250];
251
252/// Blue (0) → Green (mid) → Red (max-1)
253fn heatmap_color(pos: usize, max: usize) -> Rgb {
254    if max <= 1 {
255        return Rgb::new(0, 0, 255);
256    }
257    let t = (pos * 255) / (max - 1);
258    if t < 128 {
259        let g = (t * 2) as u8;
260        Rgb::new(0, g, 255 - g)
261    } else {
262        let r = ((t - 128) * 2) as u8;
263        Rgb::new(r, 255 - r, 0)
264    }
265}
266
267/// HSV hue (0–255) to RGB at full saturation/value.
268fn hue_to_rgb(h: u8) -> Rgb {
269    let region = h / 43;
270    let remainder = (h % 43) * 6;
271    match region {
272        0 => Rgb::new(255, remainder, 0),
273        1 => Rgb::new(255 - remainder, 255, 0),
274        2 => Rgb::new(0, 255, remainder),
275        3 => Rgb::new(0, 255 - remainder, 255),
276        4 => Rgb::new(remainder, 0, 255),
277        _ => Rgb::new(255, 0, 255 - remainder),
278    }
279}
280
281/// Half-sine: 0 → 255 → 0 over one period. Peak at period/2.
282fn sine_u8(phase: u16, period: u16) -> u8 {
283    const TABLE: [u8; 16] = [
284        0, 25, 50, 74, 98, 120, 142, 162, 180, 197, 213, 226, 237, 245, 251, 255,
285    ];
286    let pos = phase % period;
287    let half = period / 2;
288    // Distance from peak (0 at peak, half at edges)
289    let dist = half.abs_diff(pos);
290    // Map dist (0..half) to table index (15..0)
291    let idx = ((half - dist) as usize * 15) / half.max(1) as usize;
292    TABLE[idx.min(15)]
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    #[test]
300    fn off_renders_black() {
301        let ring = LedRing::default();
302        let frame = ring.render(0);
303        assert!(frame.iter().all(|px| *px == Rgb::BLACK));
304    }
305
306    #[test]
307    fn solid_renders_all_same() {
308        let mut ring = LedRing::default();
309        let c = Rgb::new(255, 0, 0);
310        ring.set(RingAnimation::solid(c));
311        let frame = ring.render(0);
312        assert!(frame.iter().all(|px| *px == c));
313    }
314
315    #[test]
316    fn glow_dims_uniformly() {
317        let mut ring = LedRing::default();
318        ring.set(RingAnimation::glow(Rgb::new(255, 255, 255)));
319        let frame = ring.render(0);
320        // All LEDs dimmed uniformly to 1/8
321        assert!(frame
322            .iter()
323            .all(|px| px.r == 32 && px.g == 32 && px.b == 32));
324    }
325
326    #[test]
327    fn blink_alternates() {
328        let mut ring = LedRing::default();
329        let c = Rgb::new(255, 0, 0);
330        ring.set(RingAnimation {
331            renderer: Renderer::Solid(c),
332            modifier: Modifier::Blink,
333        });
334        // tick 0: on
335        assert!(ring.render(0).iter().all(|px| *px == c));
336        // tick 25: off
337        assert!(ring.render(25).iter().all(|px| *px == Rgb::BLACK));
338    }
339
340    #[test]
341    fn wings_2_lights_opposing() {
342        let mut ring = LedRing::default();
343        let c = Rgb::new(0, 255, 0);
344        ring.set(RingAnimation {
345            renderer: Renderer::Dots(c, 2),
346            modifier: Modifier::Solid,
347        });
348        let frame = ring.render(0);
349        let lit = frame.iter().filter(|px| **px != Rgb::BLACK).count();
350        assert_eq!(lit, 2);
351    }
352
353    #[test]
354    fn wings_3_lights_three() {
355        let mut ring = LedRing::default();
356        ring.set(RingAnimation {
357            renderer: Renderer::Dots(Rgb::new(0, 0, 255), 3),
358            modifier: Modifier::Solid,
359        });
360        let frame = ring.render(0);
361        let lit = frame.iter().filter(|px| **px != Rgb::BLACK).count();
362        assert_eq!(lit, 3);
363    }
364
365    #[test]
366    fn heatmap_full_lights_11() {
367        let mut ring = LedRing::default();
368        ring.set(RingAnimation {
369            renderer: Renderer::Heatmap(12),
370            modifier: Modifier::Solid,
371        });
372        let frame = ring.render(0);
373        let lit = frame.iter().filter(|px| **px != Rgb::BLACK).count();
374        assert_eq!(lit, 11);
375    }
376
377    #[test]
378    fn rotate_shifts_single() {
379        let mut ring = LedRing::default();
380        let c = Rgb::new(255, 255, 0);
381        ring.set(RingAnimation {
382            renderer: Renderer::Single(c, 0),
383            modifier: Modifier::Rotate,
384        });
385        let f0 = ring.render(0);
386        let f1 = ring.render(25);
387        // Pattern should have shifted
388        assert_ne!(f0, f1);
389    }
390
391    #[test]
392    fn pulse_starts_dim() {
393        let mut ring = LedRing::default();
394        ring.set(RingAnimation {
395            renderer: Renderer::Solid(Rgb::new(255, 255, 255)),
396            modifier: Modifier::Pulse,
397        });
398        // tick=0, phase=0 → sine at edge, should be dim
399        let frame = ring.render(0);
400        assert!(frame[0].r < 30);
401    }
402
403    #[test]
404    #[test]
405    fn scale_zero_is_black() {
406        assert_eq!(Rgb::new(255, 128, 64).scale(0), Rgb::BLACK);
407    }
408
409    #[test]
410    fn scale_255_is_identity() {
411        let c = Rgb::new(200, 100, 50);
412        assert_eq!(c.scale(255), c);
413    }
414
415    #[test]
416    fn struct_sizes() {
417        assert_eq!(core::mem::size_of::<Rgb>(), 3);
418        assert_eq!(core::mem::size_of::<RingAnimation>(), 6);
419        assert!(core::mem::size_of::<LedRing>() <= 8);
420    }
421}