Skip to main content

aether_midi/
learn.rs

1//! MIDI Learn - Map MIDI CC controllers to parameters.
2//!
3//! Allows users to easily map physical MIDI controllers (knobs, faders, etc.)
4//! to software parameters by entering "learn mode" and moving the controller.
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9/// A MIDI CC mapping to a parameter.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct MidiMapping {
12    /// MIDI channel (1-16).
13    pub channel: u8,
14
15    /// MIDI CC number (0-127).
16    pub cc: u8,
17
18    /// Parameter identifier (e.g., "filter_cutoff", "gain").
19    pub param_id: String,
20
21    /// Minimum parameter value.
22    pub min_value: f32,
23
24    /// Maximum parameter value.
25    pub max_value: f32,
26
27    /// Optional curve (linear, exponential, logarithmic).
28    pub curve: MappingCurve,
29}
30
31/// Mapping curve type.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
33pub enum MappingCurve {
34    /// Linear mapping (default).
35    #[default]
36    Linear,
37    /// Exponential curve (good for frequency).
38    Exponential,
39    /// Logarithmic curve (good for gain).
40    Logarithmic,
41}
42
43/// MIDI Learn engine.
44pub struct MidiLearn {
45    /// All active mappings: (channel, cc) -> MidiMapping
46    mappings: HashMap<(u8, u8), MidiMapping>,
47
48    /// Learn mode state.
49    learn_mode: Option<LearnState>,
50}
51
52/// Learn mode state.
53#[derive(Debug, Clone)]
54struct LearnState {
55    /// Parameter ID waiting to be mapped.
56    param_id: String,
57
58    /// Min/max values for the parameter.
59    min_value: f32,
60    max_value: f32,
61
62    /// Curve type.
63    curve: MappingCurve,
64}
65
66impl MidiLearn {
67    /// Creates a new MIDI Learn engine.
68    pub fn new() -> Self {
69        Self {
70            mappings: HashMap::new(),
71            learn_mode: None,
72        }
73    }
74
75    /// Enters learn mode for a parameter.
76    ///
77    /// The next MIDI CC message received will be mapped to this parameter.
78    ///
79    /// # Arguments
80    ///
81    /// * `param_id` - Parameter identifier
82    /// * `min_value` - Minimum parameter value
83    /// * `max_value` - Maximum parameter value
84    /// * `curve` - Mapping curve type
85    ///
86    /// # Example
87    ///
88    /// ```
89    /// use aether_midi::{MidiLearn, MappingCurve};
90    ///
91    /// let mut learn = MidiLearn::new();
92    ///
93    /// // Enter learn mode for filter cutoff
94    /// learn.start_learn("filter_cutoff", 20.0, 20000.0, MappingCurve::Exponential);
95    ///
96    /// // User moves a MIDI controller...
97    /// // The next CC message will be mapped to filter_cutoff
98    /// ```
99    pub fn start_learn(
100        &mut self,
101        param_id: impl Into<String>,
102        min_value: f32,
103        max_value: f32,
104        curve: MappingCurve,
105    ) {
106        self.learn_mode = Some(LearnState {
107            param_id: param_id.into(),
108            min_value,
109            max_value,
110            curve,
111        });
112    }
113
114    /// Exits learn mode without creating a mapping.
115    pub fn cancel_learn(&mut self) {
116        self.learn_mode = None;
117    }
118
119    /// Checks if currently in learn mode.
120    pub fn is_learning(&self) -> bool {
121        self.learn_mode.is_some()
122    }
123
124    /// Processes a MIDI CC message.
125    ///
126    /// If in learn mode, creates a mapping. Otherwise, applies existing mappings.
127    ///
128    /// # Arguments
129    ///
130    /// * `channel` - MIDI channel (1-16)
131    /// * `cc` - MIDI CC number (0-127)
132    /// * `value` - MIDI CC value (0-127)
133    ///
134    /// # Returns
135    ///
136    /// If a mapping exists (or was just created), returns `Some((param_id, mapped_value))`.
137    /// Otherwise returns `None`.
138    ///
139    /// # Example
140    ///
141    /// ```
142    /// use aether_midi::{MidiLearn, MappingCurve};
143    ///
144    /// let mut learn = MidiLearn::new();
145    ///
146    /// // Enter learn mode
147    /// learn.start_learn("gain", 0.0, 1.0, MappingCurve::Linear);
148    ///
149    /// // User moves CC 7 on channel 1
150    /// let result = learn.process_cc(1, 7, 64);
151    /// assert!(result.is_some());
152    /// let (param_id, value) = result.unwrap();
153    /// assert_eq!(param_id, "gain");
154    /// assert!((value - 0.5).abs() < 0.01); // 64/127 ≈ 0.5
155    ///
156    /// // Now CC 7 is mapped to gain
157    /// let result = learn.process_cc(1, 7, 127);
158    /// assert!(result.is_some());
159    /// let (param_id, value) = result.unwrap();
160    /// assert_eq!(param_id, "gain");
161    /// assert!((value - 1.0).abs() < 0.01);
162    /// ```
163    pub fn process_cc(&mut self, channel: u8, cc: u8, value: u8) -> Option<(String, f32)> {
164        // If in learn mode, create mapping
165        if let Some(learn_state) = self.learn_mode.take() {
166            let mapping = MidiMapping {
167                channel,
168                cc,
169                param_id: learn_state.param_id.clone(),
170                min_value: learn_state.min_value,
171                max_value: learn_state.max_value,
172                curve: learn_state.curve,
173            };
174
175            self.mappings.insert((channel, cc), mapping.clone());
176
177            // Apply the mapping immediately
178            let mapped_value = Self::map_value(value, &mapping);
179            return Some((mapping.param_id, mapped_value));
180        }
181
182        // Otherwise, apply existing mapping
183        if let Some(mapping) = self.mappings.get(&(channel, cc)) {
184            let mapped_value = Self::map_value(value, mapping);
185            Some((mapping.param_id.clone(), mapped_value))
186        } else {
187            None
188        }
189    }
190
191    /// Adds a mapping manually (without learn mode).
192    ///
193    /// # Arguments
194    ///
195    /// * `channel` - MIDI channel (1-16)
196    /// * `cc` - MIDI CC number (0-127)
197    /// * `param_id` - Parameter identifier
198    /// * `min_value` - Minimum parameter value
199    /// * `max_value` - Maximum parameter value
200    /// * `curve` - Mapping curve type
201    pub fn add_mapping(
202        &mut self,
203        channel: u8,
204        cc: u8,
205        param_id: impl Into<String>,
206        min_value: f32,
207        max_value: f32,
208        curve: MappingCurve,
209    ) {
210        let mapping = MidiMapping {
211            channel,
212            cc,
213            param_id: param_id.into(),
214            min_value,
215            max_value,
216            curve,
217        };
218        self.mappings.insert((channel, cc), mapping);
219    }
220
221    /// Removes a mapping.
222    ///
223    /// # Arguments
224    ///
225    /// * `channel` - MIDI channel (1-16)
226    /// * `cc` - MIDI CC number (0-127)
227    ///
228    /// # Returns
229    ///
230    /// The removed mapping, or None if no mapping existed.
231    pub fn remove_mapping(&mut self, channel: u8, cc: u8) -> Option<MidiMapping> {
232        self.mappings.remove(&(channel, cc))
233    }
234
235    /// Gets a mapping.
236    pub fn get_mapping(&self, channel: u8, cc: u8) -> Option<&MidiMapping> {
237        self.mappings.get(&(channel, cc))
238    }
239
240    /// Gets all mappings.
241    pub fn mappings(&self) -> impl Iterator<Item = &MidiMapping> {
242        self.mappings.values()
243    }
244
245    /// Clears all mappings.
246    pub fn clear_mappings(&mut self) {
247        self.mappings.clear();
248    }
249
250    /// Saves mappings to JSON.
251    pub fn to_json(&self) -> Result<String, serde_json::Error> {
252        let mappings: Vec<_> = self.mappings.values().collect();
253        serde_json::to_string_pretty(&mappings)
254    }
255
256    /// Loads mappings from JSON.
257    pub fn from_json(&mut self, json: &str) -> Result<(), serde_json::Error> {
258        let mappings: Vec<MidiMapping> = serde_json::from_str(json)?;
259        self.mappings.clear();
260        for mapping in mappings {
261            self.mappings.insert((mapping.channel, mapping.cc), mapping);
262        }
263        Ok(())
264    }
265
266    /// Maps a MIDI CC value (0-127) to a parameter value using the mapping.
267    fn map_value(cc_value: u8, mapping: &MidiMapping) -> f32 {
268        let normalized = cc_value as f32 / 127.0; // 0.0 to 1.0
269
270        let curved = match mapping.curve {
271            MappingCurve::Linear => normalized,
272            MappingCurve::Exponential => {
273                // Exponential curve: y = x^2
274                normalized * normalized
275            }
276            MappingCurve::Logarithmic => {
277                // Logarithmic curve: y = log(1 + 9x) / log(10)
278                // Maps 0->0, 1->1, with logarithmic shape
279                if normalized <= 0.0 {
280                    0.0
281                } else {
282                    (1.0 + 9.0 * normalized).log10()
283                }
284            }
285        };
286
287        // Scale to parameter range
288        mapping.min_value + curved * (mapping.max_value - mapping.min_value)
289    }
290}
291
292impl Default for MidiLearn {
293    fn default() -> Self {
294        Self::new()
295    }
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    #[test]
303    fn test_midi_learn_basic() {
304        let mut learn = MidiLearn::new();
305
306        // Enter learn mode
307        learn.start_learn("gain", 0.0, 1.0, MappingCurve::Linear);
308        assert!(learn.is_learning());
309
310        // Process CC message - should create mapping
311        let result = learn.process_cc(1, 7, 64);
312        assert!(result.is_some());
313        let (param_id, value) = result.unwrap();
314        assert_eq!(param_id, "gain");
315        assert!((value - 0.5).abs() < 0.01);
316
317        // Should exit learn mode
318        assert!(!learn.is_learning());
319
320        // Mapping should now exist
321        assert!(learn.get_mapping(1, 7).is_some());
322    }
323
324    #[test]
325    fn test_midi_learn_cancel() {
326        let mut learn = MidiLearn::new();
327
328        learn.start_learn("gain", 0.0, 1.0, MappingCurve::Linear);
329        assert!(learn.is_learning());
330
331        learn.cancel_learn();
332        assert!(!learn.is_learning());
333
334        // No mapping should be created
335        let result = learn.process_cc(1, 7, 64);
336        assert!(result.is_none());
337    }
338
339    #[test]
340    fn test_midi_learn_apply_mapping() {
341        let mut learn = MidiLearn::new();
342
343        // Create mapping manually
344        learn.add_mapping(1, 7, "gain", 0.0, 1.0, MappingCurve::Linear);
345
346        // Apply mapping
347        let result = learn.process_cc(1, 7, 0);
348        assert_eq!(result, Some(("gain".to_string(), 0.0)));
349
350        let result = learn.process_cc(1, 7, 127);
351        assert_eq!(result, Some(("gain".to_string(), 1.0)));
352
353        let result = learn.process_cc(1, 7, 64);
354        let (_, value) = result.unwrap();
355        assert!((value - 0.5).abs() < 0.01);
356    }
357
358    #[test]
359    fn test_midi_learn_exponential_curve() {
360        let mut learn = MidiLearn::new();
361
362        learn.add_mapping(1, 7, "cutoff", 20.0, 20000.0, MappingCurve::Exponential);
363
364        // Exponential curve: more resolution at low end
365        let result = learn.process_cc(1, 7, 64);
366        let (_, value) = result.unwrap();
367        // 64/127 ≈ 0.5, squared ≈ 0.25
368        // 20 + 0.25 * 19980 ≈ 5015
369        assert!(value > 4000.0 && value < 6000.0);
370    }
371
372    #[test]
373    fn test_midi_learn_remove_mapping() {
374        let mut learn = MidiLearn::new();
375
376        learn.add_mapping(1, 7, "gain", 0.0, 1.0, MappingCurve::Linear);
377        assert!(learn.get_mapping(1, 7).is_some());
378
379        let removed = learn.remove_mapping(1, 7);
380        assert!(removed.is_some());
381        assert!(learn.get_mapping(1, 7).is_none());
382    }
383
384    #[test]
385    fn test_midi_learn_json_serialization() {
386        let mut learn = MidiLearn::new();
387
388        learn.add_mapping(1, 7, "gain", 0.0, 1.0, MappingCurve::Linear);
389        learn.add_mapping(1, 74, "cutoff", 20.0, 20000.0, MappingCurve::Exponential);
390
391        // Save to JSON
392        let json = learn.to_json().unwrap();
393        assert!(json.contains("gain"));
394        assert!(json.contains("cutoff"));
395
396        // Load from JSON
397        let mut learn2 = MidiLearn::new();
398        learn2.from_json(&json).unwrap();
399
400        assert!(learn2.get_mapping(1, 7).is_some());
401        assert!(learn2.get_mapping(1, 74).is_some());
402    }
403}