Skip to main content

gled_model/asset/midi_controller/
mod.rs

1mod output;
2
3pub use output::{
4    MidiColorSource, MidiNamedSceneColorMapping, MidiOutputBinding, MidiOutputBindingKind,
5    MidiOutputBindingSelector, MidiSceneColorMessage, MidiSceneColorMessageMap,
6    MidiSceneColorValueOutput, MidiValueOutput, MidiValueSource, MidiValueSourceKind,
7};
8
9use super::AssetTrait;
10use serde::{Deserialize, Serialize};
11use strum::{Display, EnumDiscriminants, EnumIter};
12
13#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone, Default)]
14pub struct MidiController {
15    pub mapping: MidiControllerMapping,
16}
17
18impl AssetTrait for MidiController {
19    const DIR_NAME: &'static str = "midi_controllers";
20    const NAME: &'static str = "MIDI Controller";
21    const SHOW_NAME_IF_SELECTED: bool = true;
22}
23
24#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone, Default)]
25pub struct MidiControllerMapping {
26    pub input_bindings: Vec<MidiInputBinding>,
27    pub output_bindings: Vec<MidiOutputBinding>,
28    pub color_mappings: Vec<MidiNamedSceneColorMapping>,
29}
30
31#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)]
32pub struct MidiInputBinding {
33    pub name: String,
34    pub trigger: MidiTrigger,
35    pub action: MidiInputAction,
36}
37
38impl Default for MidiInputBinding {
39    fn default() -> Self {
40        Self {
41            name: "Input".to_owned(),
42            trigger: MidiTrigger::default(),
43            action: MidiInputAction::Tap,
44        }
45    }
46}
47
48#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)]
49pub struct MidiTrigger {
50    pub status: u8,
51    pub data1: u8,
52    pub match_data1: bool,
53}
54
55impl Default for MidiTrigger {
56    fn default() -> Self {
57        Self {
58            status: 0,
59            data1: 0,
60            match_data1: true,
61        }
62    }
63}
64
65/// `MidiInputActionKind` is the action without its settings; it reads as
66/// the label the controller window shows.
67#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone, EnumDiscriminants)]
68#[strum_discriminants(
69    name(MidiInputActionKind),
70    derive(Display, EnumIter),
71    strum(serialize_all = "title_case")
72)]
73pub enum MidiInputAction {
74    Tap,
75    SetMainDimmer,
76    SetBlackout,
77    SetSpeedAdd,
78    SetSpeedMultiply,
79    #[strum_discriminants(strum(serialize = "Select Scene (Distributed 0-127)"))]
80    SelectSceneDistributed,
81    SelectScene {
82        target: MidiSceneTarget,
83    },
84    ToggleSceneActive {
85        target: MidiSceneTarget,
86    },
87    SetSceneActive {
88        target: MidiSceneTarget,
89    },
90    FlashScene {
91        target: MidiSceneTarget,
92    },
93    SetSceneOpacity {
94        target: MidiSceneTarget,
95    },
96    SetSceneInputDimmer {
97        target: MidiSceneTarget,
98    },
99    SetSceneBeatOffset {
100        target: MidiSceneTarget,
101    },
102    SetSceneIgnoreMainDimmer {
103        target: MidiSceneTarget,
104    },
105    SetSceneSetOffsetOnFlash {
106        target: MidiSceneTarget,
107    },
108    #[strum_discriminants(strum(serialize = "Set Effect Setting (n,n) f32"))]
109    SetSceneEffectSettingF32 {
110        target: MidiSceneTarget,
111        effect_index: u8,
112        setting_index: u8,
113    },
114    #[strum_discriminants(strum(serialize = "Set Effect Opacity"))]
115    SetSceneEffectOpacity {
116        target: MidiSceneTarget,
117        effect_index: u8,
118    },
119    #[strum_discriminants(strum(serialize = "Set Effect Color Shift"))]
120    SetSceneEffectColorShift {
121        target: MidiSceneTarget,
122        effect_index: u8,
123    },
124    #[strum_discriminants(strum(serialize = "Set Effect Beat Progression"))]
125    SetSceneEffectBeatProgression {
126        target: MidiSceneTarget,
127        effect_index: u8,
128    },
129    #[strum_discriminants(strum(serialize = "Set Effect Beat Offset"))]
130    SetSceneEffectBeatOffset {
131        target: MidiSceneTarget,
132        effect_index: u8,
133    },
134    #[strum_discriminants(strum(serialize = "Set Effect Speed Exponent"))]
135    SetSceneEffectSpeedExponent {
136        target: MidiSceneTarget,
137        effect_index: u8,
138    },
139    /// Picks the effect's layout: 0 inherits the tile's layout, 1 is the
140    /// topology, 2… the layouts in stack order (`Patch::layout_refs`). Was the
141    /// effect's group index before the patch.
142    #[serde(alias = "SetSceneEffectGroupIndex")]
143    #[strum_discriminants(strum(serialize = "Set Effect Layout"))]
144    SetSceneEffectLayout {
145        target: MidiSceneTarget,
146        effect_index: u8,
147    },
148}
149
150/// `MidiSceneTargetKind` is the target without its position; it reads as
151/// the label the controller window shows.
152#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone, Default, EnumDiscriminants)]
153#[strum_discriminants(
154    name(MidiSceneTargetKind),
155    derive(Display, EnumIter),
156    strum(serialize_all = "title_case")
157)]
158pub enum MidiSceneTarget {
159    #[default]
160    #[strum_discriminants(strum(serialize = "Selected Scene"))]
161    Selected,
162    #[strum_discriminants(strum(serialize = "Quick Scene"))]
163    Quick { index: u8 },
164    #[strum_discriminants(strum(serialize = "Grid Scene"))]
165    Grid { row: u8, col: u8 },
166}
167/// A port name without the number some systems append, such as
168/// `"APC40 [2]"` → `"APC40"`, so a controller keeps its mapping when it is
169/// plugged in again.
170pub fn normalize_controller_key(port_name: &str) -> String {
171    let trimmed = port_name.trim();
172    if let Some(prefix) = trimmed.strip_suffix(']')
173        && let Some((base, suffix)) = prefix.rsplit_once(" [")
174        && !base.is_empty()
175        && !suffix.is_empty()
176        && suffix.chars().all(|ch| ch.is_ascii_digit())
177    {
178        return base.to_owned();
179    }
180
181    trimmed.to_owned()
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    /// Groups became layouts; a controller saved with the group names must
189    /// still load, and then means the effect's layout.
190    #[test]
191    fn group_index_bindings_saved_before_the_patch_load_as_layout_bindings() {
192        let action: MidiInputAction = serde_json::from_str(
193            r#"{"SetSceneEffectGroupIndex": {"target": "Selected", "effect_index": 1}}"#,
194        )
195        .expect("an old group index action must load");
196        assert_eq!(
197            action,
198            MidiInputAction::SetSceneEffectLayout {
199                target: MidiSceneTarget::Selected,
200                effect_index: 1,
201            }
202        );
203
204        let source: MidiValueSource = serde_json::from_str(
205            r#"{"SceneEffectGroupIndex": {"target": "Selected", "effect_index": 1}}"#,
206        )
207        .expect("an old group index feedback must load");
208        assert_eq!(
209            source,
210            MidiValueSource::SceneEffectLayout {
211                target: MidiSceneTarget::Selected,
212                effect_index: 1,
213            }
214        );
215    }
216
217    /// The controller window lists these labels; the generated kinds must
218    /// read exactly as the hand-written labels did.
219    #[test]
220    fn kinds_read_as_the_controller_window_labels() {
221        use strum::IntoEnumIterator;
222        let actions: Vec<String> = MidiInputActionKind::iter().map(|k| k.to_string()).collect();
223        assert_eq!(
224            actions,
225            [
226                "Tap",
227                "Set Main Dimmer",
228                "Set Blackout",
229                "Set Speed Add",
230                "Set Speed Multiply",
231                "Select Scene (Distributed 0-127)",
232                "Select Scene",
233                "Toggle Scene Active",
234                "Set Scene Active",
235                "Flash Scene",
236                "Set Scene Opacity",
237                "Set Scene Input Dimmer",
238                "Set Scene Beat Offset",
239                "Set Scene Ignore Main Dimmer",
240                "Set Scene Set Offset On Flash",
241                "Set Effect Setting (n,n) f32",
242                "Set Effect Opacity",
243                "Set Effect Color Shift",
244                "Set Effect Beat Progression",
245                "Set Effect Beat Offset",
246                "Set Effect Speed Exponent",
247                "Set Effect Layout",
248            ]
249        );
250        let sources: Vec<String> = MidiValueSourceKind::iter().map(|k| k.to_string()).collect();
251        assert_eq!(
252            sources,
253            [
254                "Selected Scene Opacity",
255                "Selected Scene (Distributed 0-127)",
256                "Selected Scene Input Dimmer",
257                "Selected Scene Beat Offset",
258                "Selected Scene Ignore Main Dimmer",
259                "Selected Scene Set Offset On Flash",
260                "Main Dimmer",
261                "Beat Flank",
262                "Blackout",
263                "Scene Opacity",
264                "Scene Input Dimmer",
265                "Scene Beat Offset",
266                "Scene Ignore Main Dimmer",
267                "Scene Set Offset On Flash",
268                "Scene Active",
269                "Scene Flashed",
270                "Scene Effect Setting (n,n) f32",
271                "Effect Opacity",
272                "Effect Color Shift",
273                "Effect Beat Progression",
274                "Effect Beat Offset",
275                "Effect Speed Exponent",
276                "Effect Layout",
277            ]
278        );
279        let targets: Vec<String> = MidiSceneTargetKind::iter().map(|k| k.to_string()).collect();
280        assert_eq!(targets, ["Selected Scene", "Quick Scene", "Grid Scene"]);
281        let outputs: Vec<String> = MidiOutputBindingSelector::iter()
282            .map(|k| k.to_string())
283            .collect();
284        assert_eq!(outputs, ["Single Value", "Scene Color Mapping"]);
285        assert_eq!(MidiColorSource::default().to_string(), "Scene Color");
286    }
287}