Skip to main content

gled_model/asset/midi_controller/
mod.rs

1mod output;
2mod target;
3
4pub use output::{
5    MidiColorSource, MidiEffectParameter, MidiMeterOutput, MidiNamedSceneColorMapping,
6    MidiOutputBinding, MidiOutputBindingKind, MidiOutputBindingSelector, MidiSceneColorMessage,
7    MidiSceneColorMessageMap, MidiSceneColorValueOutput, MidiValueOutput, MidiValueSource,
8    MidiValueSourceKind,
9};
10pub use target::{MidiEffectTarget, MidiEffectTargetKind, MidiEncoder, MidiRelativeEncoding};
11
12use super::AssetTrait;
13use serde::{Deserialize, Serialize};
14use strum::{Display, EnumDiscriminants, EnumIter};
15
16#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone, Default)]
17pub struct MidiController {
18    pub mapping: MidiControllerMapping,
19}
20
21impl AssetTrait for MidiController {
22    const DIR_NAME: &'static str = "midi_controllers";
23    const NAME: &'static str = "MIDI Controller";
24    const SHOW_NAME_IF_SELECTED: bool = true;
25}
26
27#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone, Default)]
28pub struct MidiControllerMapping {
29    pub input_bindings: Vec<MidiInputBinding>,
30    pub output_bindings: Vec<MidiOutputBinding>,
31    pub color_mappings: Vec<MidiNamedSceneColorMapping>,
32}
33
34#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)]
35pub struct MidiInputBinding {
36    pub name: String,
37    pub trigger: MidiTrigger,
38    pub action: MidiInputAction,
39}
40
41impl Default for MidiInputBinding {
42    fn default() -> Self {
43        Self {
44            name: "Input".to_owned(),
45            trigger: MidiTrigger::default(),
46            action: MidiInputAction::Tap,
47        }
48    }
49}
50
51#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)]
52pub struct MidiTrigger {
53    pub status: u8,
54    pub data1: u8,
55    pub match_data1: bool,
56}
57
58impl Default for MidiTrigger {
59    fn default() -> Self {
60        Self {
61            status: 0,
62            data1: 0,
63            match_data1: true,
64        }
65    }
66}
67
68/// `MidiInputActionKind` is the action without its settings; it reads as
69/// the label the controller window shows.
70#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone, EnumDiscriminants)]
71#[strum_discriminants(
72    name(MidiInputActionKind),
73    derive(Display, EnumIter),
74    strum(serialize_all = "title_case")
75)]
76pub enum MidiInputAction {
77    Tap,
78    SetMainDimmer,
79    /// Moves the main dimmer by an encoder's units, 1/127 each.
80    #[strum_discriminants(strum(serialize = "Set Main Dimmer (Relative)"))]
81    SetMainDimmerRelative {
82        encoder: MidiEncoder,
83    },
84    SetBlackout,
85    /// Flips the blackout on a press (any value above 0).
86    ToggleBlackout,
87    /// Adds the value as two's complement steps to the tempo, one BPM each.
88    SetSpeedAdd,
89    SetSpeedMultiply,
90    /// Moves the tempo by an encoder's units, one BPM each.
91    #[strum_discriminants(strum(serialize = "Set Speed (Relative)"))]
92    SetSpeedRelative {
93        encoder: MidiEncoder,
94    },
95    #[strum_discriminants(strum(serialize = "Select Scene (Distributed 0-127)"))]
96    SelectSceneDistributed,
97    /// Selects the scene an encoder's units away, in row-major order.
98    #[strum_discriminants(strum(serialize = "Select Scene (Relative)"))]
99    SelectSceneRelative {
100        encoder: MidiEncoder,
101    },
102    SelectScene {
103        target: MidiSceneTarget,
104    },
105    /// Selects an effect of the selected scene on a press.
106    #[strum_discriminants(strum(serialize = "Select Effect"))]
107    SelectSceneEffect {
108        index: u8,
109    },
110    /// Selects the effect an encoder's units away in the selected scene.
111    #[strum_discriminants(strum(serialize = "Select Effect (Relative)"))]
112    SelectSceneEffectRelative {
113        encoder: MidiEncoder,
114    },
115    /// Spreads the value over the selected scene's effects.
116    #[strum_discriminants(strum(serialize = "Select Effect (Distributed 0-127)"))]
117    SelectSceneEffectDistributed,
118    ToggleSceneActive {
119        target: MidiSceneTarget,
120    },
121    SetSceneActive {
122        target: MidiSceneTarget,
123    },
124    FlashScene {
125        target: MidiSceneTarget,
126    },
127    SetSceneOpacity {
128        target: MidiSceneTarget,
129    },
130    SetSceneInputDimmer {
131        target: MidiSceneTarget,
132    },
133    SetSceneBeatOffset {
134        target: MidiSceneTarget,
135    },
136    SetSceneIgnoreMainDimmer {
137        target: MidiSceneTarget,
138    },
139    SetSceneSetOffsetOnFlash {
140        target: MidiSceneTarget,
141    },
142    #[strum_discriminants(strum(serialize = "Set Effect Setting (n,n) f32"))]
143    SetSceneEffectSettingF32 {
144        target: MidiSceneTarget,
145        #[serde(rename = "effect_index")]
146        effect: MidiEffectTarget,
147        setting_index: u8,
148    },
149    #[strum_discriminants(strum(serialize = "Set Effect Opacity"))]
150    SetSceneEffectOpacity {
151        target: MidiSceneTarget,
152        #[serde(rename = "effect_index")]
153        effect: MidiEffectTarget,
154    },
155    #[strum_discriminants(strum(serialize = "Set Effect Color Shift"))]
156    SetSceneEffectColorShift {
157        target: MidiSceneTarget,
158        #[serde(rename = "effect_index")]
159        effect: MidiEffectTarget,
160    },
161    #[strum_discriminants(strum(serialize = "Set Effect Beat Progression"))]
162    SetSceneEffectBeatProgression {
163        target: MidiSceneTarget,
164        #[serde(rename = "effect_index")]
165        effect: MidiEffectTarget,
166    },
167    #[strum_discriminants(strum(serialize = "Set Effect Beat Offset"))]
168    SetSceneEffectBeatOffset {
169        target: MidiSceneTarget,
170        #[serde(rename = "effect_index")]
171        effect: MidiEffectTarget,
172    },
173    #[strum_discriminants(strum(serialize = "Set Effect Speed Exponent"))]
174    SetSceneEffectSpeedExponent {
175        target: MidiSceneTarget,
176        #[serde(rename = "effect_index")]
177        effect: MidiEffectTarget,
178    },
179    /// Picks the effect's layout: 0 inherits the tile's layout, 1 is the
180    /// topology, 2… the layouts in stack order (`Patch::layout_refs`). Was the
181    /// effect's group index before the patch.
182    #[serde(alias = "SetSceneEffectGroupIndex")]
183    #[strum_discriminants(strum(serialize = "Set Effect Layout"))]
184    SetSceneEffectLayout {
185        target: MidiSceneTarget,
186        #[serde(rename = "effect_index")]
187        effect: MidiEffectTarget,
188    },
189}
190
191/// `MidiSceneTargetKind` is the target without its position; it reads as
192/// the label the controller window shows.
193#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone, Default, EnumDiscriminants)]
194#[strum_discriminants(
195    name(MidiSceneTargetKind),
196    derive(Display, EnumIter),
197    strum(serialize_all = "title_case")
198)]
199pub enum MidiSceneTarget {
200    #[default]
201    #[strum_discriminants(strum(serialize = "Selected Scene"))]
202    Selected,
203    #[strum_discriminants(strum(serialize = "Quick Scene"))]
204    Quick { index: u8 },
205    #[strum_discriminants(strum(serialize = "Grid Scene"))]
206    Grid { row: u8, col: u8 },
207}
208/// A port name without the numbers some systems append, such as
209/// `"APC40 [2]"` → `"APC40"`, or ALSA's client and port address,
210/// `"iCON P1-X1:iCON P1-X1 MIDI 1 32:0"` → `"iCON P1-X1:iCON P1-X1 MIDI 1"`,
211/// which changes with the order devices are plugged in. A controller so
212/// keeps its mapping when it is plugged in again.
213pub fn normalize_controller_key(port_name: &str) -> String {
214    let trimmed = strip_alsa_address(port_name.trim());
215    if let Some(prefix) = trimmed.strip_suffix(']')
216        && let Some((base, suffix)) = prefix.rsplit_once(" [")
217        && !base.is_empty()
218        && !suffix.is_empty()
219        && suffix.chars().all(|ch| ch.is_ascii_digit())
220    {
221        return base.to_owned();
222    }
223
224    trimmed.to_owned()
225}
226
227/// `name` without a trailing `" <client>:<port>"` address.
228fn strip_alsa_address(name: &str) -> &str {
229    let Some((base, address)) = name.rsplit_once(' ') else {
230        return name;
231    };
232    let is_number = |part: &str| !part.is_empty() && part.chars().all(|ch| ch.is_ascii_digit());
233    match address.split_once(':') {
234        Some((client, port)) if !base.is_empty() && is_number(client) && is_number(port) => {
235            base.trim_end()
236        }
237        _ => name,
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    #[test]
246    fn a_full_meter_lights_all_twelve_leds_of_its_strip() {
247        let meter = MidiMeterOutput {
248            strip: 2,
249            source: MidiValueSource::default(),
250        };
251        assert_eq!(meter.message(1.0), [0xD0, 0x2C]);
252        assert_eq!(meter.message(0.0), [0xD0, 0x20]);
253        assert_eq!(meter.message(2.0), [0xD0, 0x2C]);
254    }
255
256    #[test]
257    fn alsa_addresses_are_not_part_of_the_controller_key() {
258        assert_eq!(
259            normalize_controller_key("iCON P1-X1:iCON P1-X1 MIDI 1 32:0"),
260            "iCON P1-X1:iCON P1-X1 MIDI 1"
261        );
262        assert_eq!(
263            normalize_controller_key("iCON P1-X1:iCON P1-X1 MIDI 1 36:0"),
264            normalize_controller_key("iCON P1-X1:iCON P1-X1 MIDI 1 32:0")
265        );
266    }
267
268    #[test]
269    fn numbered_port_names_keep_their_number() {
270        assert_eq!(normalize_controller_key("APC40 [2]"), "APC40");
271        assert_eq!(normalize_controller_key("Port 2"), "Port 2");
272    }
273
274    /// Groups became layouts; a controller saved with the group names must
275    /// still load, and then means the effect's layout.
276    #[test]
277    fn group_index_bindings_saved_before_the_patch_load_as_layout_bindings() {
278        let action: MidiInputAction = serde_json::from_str(
279            r#"{"SetSceneEffectGroupIndex": {"target": "Selected", "effect_index": 1}}"#,
280        )
281        .expect("an old group index action must load");
282        assert_eq!(
283            action,
284            MidiInputAction::SetSceneEffectLayout {
285                target: MidiSceneTarget::Selected,
286                effect: MidiEffectTarget::Index { index: 1 },
287            }
288        );
289
290        let source: MidiValueSource = serde_json::from_str(
291            r#"{"SceneEffectGroupIndex": {"target": "Selected", "effect_index": 1}}"#,
292        )
293        .expect("an old group index feedback must load");
294        assert_eq!(
295            source,
296            MidiValueSource::SceneEffectLayout {
297                target: MidiSceneTarget::Selected,
298                effect: MidiEffectTarget::Index { index: 1 },
299            }
300        );
301    }
302
303    /// The controller window lists these labels; the generated kinds must
304    /// read exactly as the hand-written labels did.
305    #[test]
306    fn kinds_read_as_the_controller_window_labels() {
307        use strum::IntoEnumIterator;
308        let actions: Vec<String> = MidiInputActionKind::iter().map(|k| k.to_string()).collect();
309        assert_eq!(
310            actions,
311            [
312                "Tap",
313                "Set Main Dimmer",
314                "Set Main Dimmer (Relative)",
315                "Set Blackout",
316                "Toggle Blackout",
317                "Set Speed Add",
318                "Set Speed Multiply",
319                "Set Speed (Relative)",
320                "Select Scene (Distributed 0-127)",
321                "Select Scene (Relative)",
322                "Select Scene",
323                "Select Effect",
324                "Select Effect (Relative)",
325                "Select Effect (Distributed 0-127)",
326                "Toggle Scene Active",
327                "Set Scene Active",
328                "Flash Scene",
329                "Set Scene Opacity",
330                "Set Scene Input Dimmer",
331                "Set Scene Beat Offset",
332                "Set Scene Ignore Main Dimmer",
333                "Set Scene Set Offset On Flash",
334                "Set Effect Setting (n,n) f32",
335                "Set Effect Opacity",
336                "Set Effect Color Shift",
337                "Set Effect Beat Progression",
338                "Set Effect Beat Offset",
339                "Set Effect Speed Exponent",
340                "Set Effect Layout",
341            ]
342        );
343        let sources: Vec<String> = MidiValueSourceKind::iter().map(|k| k.to_string()).collect();
344        assert_eq!(
345            sources,
346            [
347                "Selected Scene Opacity",
348                "Selected Scene (Distributed 0-127)",
349                "Selected Effect (Distributed 0-127)",
350                "Selected Scene Opacity Live",
351                "Effect Live Value",
352                "Effect Selected",
353                "Selected Scene Input Dimmer",
354                "Selected Scene Beat Offset",
355                "Selected Scene Ignore Main Dimmer",
356                "Selected Scene Set Offset On Flash",
357                "Main Dimmer",
358                "Beat Flank",
359                "Blackout",
360                "Scene Opacity",
361                "Scene Input Dimmer",
362                "Scene Beat Offset",
363                "Scene Ignore Main Dimmer",
364                "Scene Set Offset On Flash",
365                "Scene Active",
366                "Scene Flashed",
367                "Scene Effect Setting (n,n) f32",
368                "Effect Opacity",
369                "Effect Color Shift",
370                "Effect Beat Progression",
371                "Effect Beat Offset",
372                "Effect Speed Exponent",
373                "Effect Layout",
374            ]
375        );
376        let targets: Vec<String> = MidiSceneTargetKind::iter().map(|k| k.to_string()).collect();
377        assert_eq!(targets, ["Selected Scene", "Quick Scene", "Grid Scene"]);
378        let effects: Vec<String> = MidiEffectTargetKind::iter()
379            .map(|k| k.to_string())
380            .collect();
381        assert_eq!(effects, ["Selected Effect", "Effect Index"]);
382        let outputs: Vec<String> = MidiOutputBindingSelector::iter()
383            .map(|k| k.to_string())
384            .collect();
385        assert_eq!(
386            outputs,
387            ["Single Value", "Scene Color Mapping", "Mackie Meter"]
388        );
389        assert_eq!(MidiColorSource::default().to_string(), "Scene Color");
390    }
391}