Skip to main content

gled_model/asset/midi_controller/
mod.rs

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