gled-model 2.31.1

The data model of gled: projects, scenes, effects, animations, curves and assets
Documentation
mod output;
mod target;

pub use output::{
    MidiColorSource, MidiEffectParameter, MidiMeterOutput, MidiNamedSceneColorMapping,
    MidiOutputBinding, MidiOutputBindingKind, MidiOutputBindingSelector, MidiSceneColorMessage,
    MidiSceneColorMessageMap, MidiSceneColorValueOutput, MidiValueOutput, MidiValueSource,
    MidiValueSourceKind,
};
pub use target::{MidiEffectTarget, MidiEffectTargetKind, MidiEncoder, MidiRelativeEncoding};

use super::AssetTrait;
use serde::{Deserialize, Serialize};
use strum::{Display, EnumDiscriminants, EnumIter};

#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone, Default)]
pub struct MidiController {
    pub mapping: MidiControllerMapping,
}

impl AssetTrait for MidiController {
    const DIR_NAME: &'static str = "midi_controllers";
    const NAME: &'static str = "MIDI Controller";
    const SHOW_NAME_IF_SELECTED: bool = true;
}

#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone, Default)]
pub struct MidiControllerMapping {
    pub input_bindings: Vec<MidiInputBinding>,
    pub output_bindings: Vec<MidiOutputBinding>,
    pub color_mappings: Vec<MidiNamedSceneColorMapping>,
}

#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)]
pub struct MidiInputBinding {
    pub name: String,
    pub trigger: MidiTrigger,
    pub action: MidiInputAction,
}

impl Default for MidiInputBinding {
    fn default() -> Self {
        Self {
            name: "Input".to_owned(),
            trigger: MidiTrigger::default(),
            action: MidiInputAction::Tap,
        }
    }
}

#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)]
pub struct MidiTrigger {
    pub status: u8,
    pub data1: u8,
    pub match_data1: bool,
}

impl Default for MidiTrigger {
    fn default() -> Self {
        Self {
            status: 0,
            data1: 0,
            match_data1: true,
        }
    }
}

/// `MidiInputActionKind` is the action without its settings; it reads as
/// the label the controller window shows.
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone, EnumDiscriminants)]
#[strum_discriminants(
    name(MidiInputActionKind),
    derive(Display, EnumIter),
    strum(serialize_all = "title_case")
)]
pub enum MidiInputAction {
    Tap,
    SetMainDimmer,
    /// Moves the main dimmer by an encoder's units, 1/127 each.
    #[strum_discriminants(strum(serialize = "Set Main Dimmer (Relative)"))]
    SetMainDimmerRelative {
        encoder: MidiEncoder,
    },
    SetBlackout,
    /// Flips the blackout on a press (any value above 0).
    ToggleBlackout,
    /// Adds the value as two's complement steps to the tempo, one BPM each.
    SetSpeedAdd,
    SetSpeedMultiply,
    /// Moves the tempo by an encoder's units, one BPM each.
    #[strum_discriminants(strum(serialize = "Set Speed (Relative)"))]
    SetSpeedRelative {
        encoder: MidiEncoder,
    },
    #[strum_discriminants(strum(serialize = "Select Scene (Distributed 0-127)"))]
    SelectSceneDistributed,
    /// Selects the scene an encoder's units away, in row-major order.
    #[strum_discriminants(strum(serialize = "Select Scene (Relative)"))]
    SelectSceneRelative {
        encoder: MidiEncoder,
    },
    SelectScene {
        target: MidiSceneTarget,
    },
    /// Selects an effect of the selected scene on a press.
    #[strum_discriminants(strum(serialize = "Select Effect"))]
    SelectSceneEffect {
        index: u8,
    },
    /// Selects the effect an encoder's units away in the selected scene.
    #[strum_discriminants(strum(serialize = "Select Effect (Relative)"))]
    SelectSceneEffectRelative {
        encoder: MidiEncoder,
    },
    /// Spreads the value over the selected scene's effects.
    #[strum_discriminants(strum(serialize = "Select Effect (Distributed 0-127)"))]
    SelectSceneEffectDistributed,
    ToggleSceneActive {
        target: MidiSceneTarget,
    },
    SetSceneActive {
        target: MidiSceneTarget,
    },
    FlashScene {
        target: MidiSceneTarget,
    },
    SetSceneOpacity {
        target: MidiSceneTarget,
    },
    SetSceneInputDimmer {
        target: MidiSceneTarget,
    },
    SetSceneBeatOffset {
        target: MidiSceneTarget,
    },
    SetSceneIgnoreMainDimmer {
        target: MidiSceneTarget,
    },
    SetSceneSetOffsetOnFlash {
        target: MidiSceneTarget,
    },
    #[strum_discriminants(strum(serialize = "Set Effect Setting (n,n) f32"))]
    SetSceneEffectSettingF32 {
        target: MidiSceneTarget,
        #[serde(rename = "effect_index")]
        effect: MidiEffectTarget,
        setting_index: u8,
    },
    #[strum_discriminants(strum(serialize = "Set Effect Opacity"))]
    SetSceneEffectOpacity {
        target: MidiSceneTarget,
        #[serde(rename = "effect_index")]
        effect: MidiEffectTarget,
    },
    #[strum_discriminants(strum(serialize = "Set Effect Color Shift"))]
    SetSceneEffectColorShift {
        target: MidiSceneTarget,
        #[serde(rename = "effect_index")]
        effect: MidiEffectTarget,
    },
    #[strum_discriminants(strum(serialize = "Set Effect Beat Progression"))]
    SetSceneEffectBeatProgression {
        target: MidiSceneTarget,
        #[serde(rename = "effect_index")]
        effect: MidiEffectTarget,
    },
    #[strum_discriminants(strum(serialize = "Set Effect Beat Offset"))]
    SetSceneEffectBeatOffset {
        target: MidiSceneTarget,
        #[serde(rename = "effect_index")]
        effect: MidiEffectTarget,
    },
    #[strum_discriminants(strum(serialize = "Set Effect Speed Exponent"))]
    SetSceneEffectSpeedExponent {
        target: MidiSceneTarget,
        #[serde(rename = "effect_index")]
        effect: MidiEffectTarget,
    },
    /// Picks the effect's layout: 0 inherits the tile's layout, 1 is the
    /// topology, 2… the layouts in stack order (`Patch::layout_refs`). Was the
    /// effect's group index before the patch.
    #[serde(alias = "SetSceneEffectGroupIndex")]
    #[strum_discriminants(strum(serialize = "Set Effect Layout"))]
    SetSceneEffectLayout {
        target: MidiSceneTarget,
        #[serde(rename = "effect_index")]
        effect: MidiEffectTarget,
    },
}

/// `MidiSceneTargetKind` is the target without its position; it reads as
/// the label the controller window shows.
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone, Default, EnumDiscriminants)]
#[strum_discriminants(
    name(MidiSceneTargetKind),
    derive(Display, EnumIter),
    strum(serialize_all = "title_case")
)]
pub enum MidiSceneTarget {
    #[default]
    #[strum_discriminants(strum(serialize = "Selected Scene"))]
    Selected,
    #[strum_discriminants(strum(serialize = "Quick Scene"))]
    Quick { index: u8 },
    #[strum_discriminants(strum(serialize = "Grid Scene"))]
    Grid { row: u8, col: u8 },
}
/// A port name without the numbers some systems append, such as
/// `"APC40 [2]"` → `"APC40"`, or ALSA's client and port address,
/// `"iCON P1-X1:iCON P1-X1 MIDI 1 32:0"` → `"iCON P1-X1:iCON P1-X1 MIDI 1"`,
/// which changes with the order devices are plugged in. A controller so
/// keeps its mapping when it is plugged in again.
pub fn normalize_controller_key(port_name: &str) -> String {
    let trimmed = strip_alsa_address(port_name.trim());
    if let Some(prefix) = trimmed.strip_suffix(']')
        && let Some((base, suffix)) = prefix.rsplit_once(" [")
        && !base.is_empty()
        && !suffix.is_empty()
        && suffix.chars().all(|ch| ch.is_ascii_digit())
    {
        return base.to_owned();
    }

    trimmed.to_owned()
}

/// `name` without a trailing `" <client>:<port>"` address.
fn strip_alsa_address(name: &str) -> &str {
    let Some((base, address)) = name.rsplit_once(' ') else {
        return name;
    };
    let is_number = |part: &str| !part.is_empty() && part.chars().all(|ch| ch.is_ascii_digit());
    match address.split_once(':') {
        Some((client, port)) if !base.is_empty() && is_number(client) && is_number(port) => {
            base.trim_end()
        }
        _ => name,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn a_full_meter_lights_all_twelve_leds_of_its_strip() {
        let meter = MidiMeterOutput {
            strip: 2,
            source: MidiValueSource::default(),
        };
        assert_eq!(meter.message(1.0), [0xD0, 0x2C]);
        assert_eq!(meter.message(0.0), [0xD0, 0x20]);
        assert_eq!(meter.message(2.0), [0xD0, 0x2C]);
    }

    #[test]
    fn alsa_addresses_are_not_part_of_the_controller_key() {
        assert_eq!(
            normalize_controller_key("iCON P1-X1:iCON P1-X1 MIDI 1 32:0"),
            "iCON P1-X1:iCON P1-X1 MIDI 1"
        );
        assert_eq!(
            normalize_controller_key("iCON P1-X1:iCON P1-X1 MIDI 1 36:0"),
            normalize_controller_key("iCON P1-X1:iCON P1-X1 MIDI 1 32:0")
        );
    }

    #[test]
    fn numbered_port_names_keep_their_number() {
        assert_eq!(normalize_controller_key("APC40 [2]"), "APC40");
        assert_eq!(normalize_controller_key("Port 2"), "Port 2");
    }

    /// Groups became layouts; a controller saved with the group names must
    /// still load, and then means the effect's layout.
    #[test]
    fn group_index_bindings_saved_before_the_patch_load_as_layout_bindings() {
        let action: MidiInputAction = serde_json::from_str(
            r#"{"SetSceneEffectGroupIndex": {"target": "Selected", "effect_index": 1}}"#,
        )
        .expect("an old group index action must load");
        assert_eq!(
            action,
            MidiInputAction::SetSceneEffectLayout {
                target: MidiSceneTarget::Selected,
                effect: MidiEffectTarget::Index { index: 1 },
            }
        );

        let source: MidiValueSource = serde_json::from_str(
            r#"{"SceneEffectGroupIndex": {"target": "Selected", "effect_index": 1}}"#,
        )
        .expect("an old group index feedback must load");
        assert_eq!(
            source,
            MidiValueSource::SceneEffectLayout {
                target: MidiSceneTarget::Selected,
                effect: MidiEffectTarget::Index { index: 1 },
            }
        );
    }

    /// The controller window lists these labels; the generated kinds must
    /// read exactly as the hand-written labels did.
    #[test]
    fn kinds_read_as_the_controller_window_labels() {
        use strum::IntoEnumIterator;
        let actions: Vec<String> = MidiInputActionKind::iter().map(|k| k.to_string()).collect();
        assert_eq!(
            actions,
            [
                "Tap",
                "Set Main Dimmer",
                "Set Main Dimmer (Relative)",
                "Set Blackout",
                "Toggle Blackout",
                "Set Speed Add",
                "Set Speed Multiply",
                "Set Speed (Relative)",
                "Select Scene (Distributed 0-127)",
                "Select Scene (Relative)",
                "Select Scene",
                "Select Effect",
                "Select Effect (Relative)",
                "Select Effect (Distributed 0-127)",
                "Toggle Scene Active",
                "Set Scene Active",
                "Flash Scene",
                "Set Scene Opacity",
                "Set Scene Input Dimmer",
                "Set Scene Beat Offset",
                "Set Scene Ignore Main Dimmer",
                "Set Scene Set Offset On Flash",
                "Set Effect Setting (n,n) f32",
                "Set Effect Opacity",
                "Set Effect Color Shift",
                "Set Effect Beat Progression",
                "Set Effect Beat Offset",
                "Set Effect Speed Exponent",
                "Set Effect Layout",
            ]
        );
        let sources: Vec<String> = MidiValueSourceKind::iter().map(|k| k.to_string()).collect();
        assert_eq!(
            sources,
            [
                "Selected Scene Opacity",
                "Selected Scene (Distributed 0-127)",
                "Selected Effect (Distributed 0-127)",
                "Selected Scene Opacity Live",
                "Effect Live Value",
                "Effect Selected",
                "Selected Scene Input Dimmer",
                "Selected Scene Beat Offset",
                "Selected Scene Ignore Main Dimmer",
                "Selected Scene Set Offset On Flash",
                "Main Dimmer",
                "Beat Flank",
                "Blackout",
                "Scene Opacity",
                "Scene Input Dimmer",
                "Scene Beat Offset",
                "Scene Ignore Main Dimmer",
                "Scene Set Offset On Flash",
                "Scene Active",
                "Scene Flashed",
                "Scene Effect Setting (n,n) f32",
                "Effect Opacity",
                "Effect Color Shift",
                "Effect Beat Progression",
                "Effect Beat Offset",
                "Effect Speed Exponent",
                "Effect Layout",
            ]
        );
        let targets: Vec<String> = MidiSceneTargetKind::iter().map(|k| k.to_string()).collect();
        assert_eq!(targets, ["Selected Scene", "Quick Scene", "Grid Scene"]);
        let effects: Vec<String> = MidiEffectTargetKind::iter()
            .map(|k| k.to_string())
            .collect();
        assert_eq!(effects, ["Selected Effect", "Effect Index"]);
        let outputs: Vec<String> = MidiOutputBindingSelector::iter()
            .map(|k| k.to_string())
            .collect();
        assert_eq!(
            outputs,
            ["Single Value", "Scene Color Mapping", "Mackie Meter"]
        );
        assert_eq!(MidiColorSource::default().to_string(), "Scene Color");
    }
}