bbx_draw/visualizers/
midi_activity.rs

1//! MIDI note activity visualizer.
2
3use bbx_midi::message::MidiMessageStatus;
4use nannou::{Draw, color::Rgb, geom::Rect};
5
6use crate::{Visualizer, bridge::MidiBridgeConsumer, color::lerp_color, config::MidiActivityConfig};
7
8/// Visualizes MIDI note activity with decay animation.
9///
10/// Active notes light up based on velocity, then decay after note off.
11pub struct MidiActivityVisualizer {
12    consumer: MidiBridgeConsumer,
13    config: MidiActivityConfig,
14    note_activity: [f32; 128],
15    note_velocities: [u8; 128],
16    decay_per_frame: f32,
17}
18
19impl MidiActivityVisualizer {
20    /// Create a new MIDI activity visualizer with default configuration.
21    pub fn new(consumer: MidiBridgeConsumer) -> Self {
22        let config = MidiActivityConfig::default();
23        let decay_per_frame = 1.0 / (config.decay_time_ms / 16.67);
24        Self {
25            consumer,
26            config,
27            note_activity: [0.0; 128],
28            note_velocities: [0; 128],
29            decay_per_frame,
30        }
31    }
32
33    /// Create a new MIDI activity visualizer with custom configuration.
34    pub fn with_config(consumer: MidiBridgeConsumer, config: MidiActivityConfig) -> Self {
35        let decay_per_frame = 1.0 / (config.decay_time_ms / 16.67);
36        Self {
37            consumer,
38            note_activity: [0.0; 128],
39            note_velocities: [0; 128],
40            decay_per_frame,
41            config,
42        }
43    }
44
45    /// Get the current configuration.
46    pub fn config(&self) -> &MidiActivityConfig {
47        &self.config
48    }
49
50    /// Update the configuration.
51    pub fn set_config(&mut self, config: MidiActivityConfig) {
52        self.decay_per_frame = 1.0 / (config.decay_time_ms / 16.67);
53        self.config = config;
54    }
55
56    fn note_color(&self, note: u8) -> Rgb {
57        let activity = self.note_activity[note as usize];
58        if activity <= 0.0 {
59            return nannou::color::rgb(
60                self.config.note_off_color.red,
61                self.config.note_off_color.green,
62                self.config.note_off_color.blue,
63            );
64        }
65
66        let base_color = if self.config.velocity_brightness {
67            let velocity_factor = self.note_velocities[note as usize] as f32 / 127.0;
68            let r = self.config.note_on_color.red * velocity_factor;
69            let g = self.config.note_on_color.green * velocity_factor;
70            let b = self.config.note_on_color.blue * velocity_factor;
71            nannou::color::Srgb::new((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8)
72        } else {
73            nannou::color::Srgb::new(
74                (self.config.note_on_color.red * 255.0) as u8,
75                (self.config.note_on_color.green * 255.0) as u8,
76                (self.config.note_on_color.blue * 255.0) as u8,
77            )
78        };
79
80        let off_color = nannou::color::Srgb::new(
81            (self.config.note_off_color.red * 255.0) as u8,
82            (self.config.note_off_color.green * 255.0) as u8,
83            (self.config.note_off_color.blue * 255.0) as u8,
84        );
85
86        lerp_color(off_color, base_color, activity)
87    }
88}
89
90impl Visualizer for MidiActivityVisualizer {
91    fn update(&mut self) {
92        let messages = self.consumer.drain();
93
94        for msg in messages {
95            let status = msg.get_status();
96            if let Some(note) = msg.get_note_number() {
97                match status {
98                    MidiMessageStatus::NoteOn => {
99                        if let Some(velocity) = msg.get_velocity() {
100                            if velocity > 0 {
101                                self.note_activity[note as usize] = 1.0;
102                                self.note_velocities[note as usize] = velocity;
103                            } else {
104                                self.note_activity[note as usize] = 0.9;
105                            }
106                        }
107                    }
108                    MidiMessageStatus::NoteOff => {
109                        self.note_activity[note as usize] = 0.9;
110                    }
111                    _ => {}
112                }
113            }
114        }
115
116        for activity in &mut self.note_activity {
117            if *activity > 0.0 && *activity < 1.0 {
118                *activity -= self.decay_per_frame;
119                *activity = activity.max(0.0);
120            }
121        }
122    }
123
124    fn draw(&self, draw: &Draw, bounds: Rect) {
125        let (min_note, max_note) = self.config.display_range;
126        let num_notes = (max_note - min_note + 1) as usize;
127
128        if num_notes == 0 {
129            return;
130        }
131
132        let key_width = bounds.w() / num_notes as f32;
133        let key_height = bounds.h();
134
135        for i in 0..num_notes {
136            let note = min_note + i as u8;
137            let x = bounds.left() + (i as f32 + 0.5) * key_width;
138            let y = bounds.y();
139
140            let color = self.note_color(note);
141
142            let is_black_key = matches!(note % 12, 1 | 3 | 6 | 8 | 10);
143            let actual_height = if is_black_key { key_height * 0.65 } else { key_height };
144
145            draw.rect().x_y(x, y).w_h(key_width * 0.9, actual_height).color(color);
146        }
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use crate::bridge::midi_bridge;
154
155    #[test]
156    fn test_midi_visualizer_creation() {
157        let (_producer, consumer) = midi_bridge(64);
158        let visualizer = MidiActivityVisualizer::new(consumer);
159        assert_eq!(visualizer.note_activity.len(), 128);
160    }
161
162    #[test]
163    fn test_display_range() {
164        let (_producer, consumer) = midi_bridge(64);
165        let config = MidiActivityConfig {
166            display_range: (60, 72),
167            ..Default::default()
168        };
169        let visualizer = MidiActivityVisualizer::with_config(consumer, config);
170        assert_eq!(visualizer.config.display_range, (60, 72));
171    }
172}