Skip to main content

gled_model/asset/midi_controller/
mod.rs

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