gled-protocol 2.31.0

What gled's user interfaces and its engine send each other
Documentation
//! What the engine publishes to its user interfaces, and what they send it
//! besides actions. In-process the desktop app gets these over channels;
//! remote interfaces get them as `message`s.

use std::{
    collections::{BTreeMap, BTreeSet},
    sync::Arc,
};

use gled_model::{
    asset::{
        animation::Animation,
        output_device::routing::{OutputRouting, OutputRoutings},
        project::{Project, io::ArtnetConfig},
        scene::grid::GridLocation,
    },
    asset_id::AssetId,
    input::key_code::KeyCode,
    patching::color_orders::ColorOrders,
    sound::{AudioDeviceId, trigger_params::FREQ_BINS, trigger_ref::SoundLevels},
    timing::BeatState,
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;

use crate::{
    action::UiAction,
    assets::{AnyAsset, AnyAssetId},
    bytes::SharedBytes,
    midi::TestCommand,
};

/// Which universes are rendered, where they are routed and in which channel
/// order their lamps take red, green and blue.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct OutputConfig {
    pub universes: Arc<BTreeSet<u16>>,
    pub routings: Arc<OutputRoutings>,
    /// Lamps that are not RGB, derived from the patch with the universes.
    #[serde(default)]
    pub color_orders: Arc<ColorOrders>,
}

/// How the render thread keeps up, over the last second.
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
pub struct RenderStats {
    pub fps: f32,
    /// The longest time between two frames.
    pub max_frame_ms: f32,
}

/// What the machine running the engine is doing besides the show.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct ServerStatus {
    /// What the asset repository is busy with, `None` while idle.
    pub storage_task: Option<String>,
    /// How far the task is, 0 to 1, when it can tell.
    pub storage_progress: Option<f32>,
    pub storage_error: Option<String>,
    /// The repository is being read, written or synchronized.
    pub storage_working: bool,
    /// Changed files not yet committed.
    pub staged_files: usize,
    pub branches: Vec<String>,
    pub current_branch: Option<String>,
    /// Where the asset repository is on the engine's machine.
    pub storage_dir: String,
    /// Art-Net and DMX traffic in Mbit/s.
    pub network_in: f32,
    pub network_out: f32,
    /// The hottest temperature sensor of the engine's machine in °C, `None`
    /// on a machine without one.
    pub temperature: Option<f32>,
    /// The engine machine's host name, which tells its readings apart from
    /// those of the machine showing the interface.
    pub host_name: String,
    /// Other Ableton Link peers.
    pub link_peers: u64,
    pub settings: ServerSettings,
    /// The running Art-Net input: the project's settings and, per universe
    /// seen, how much data arrived.
    pub artnet: ArtnetConfig,
    /// The devices of the engine's machine, while an interface watches them.
    pub devices: Devices,
}

/// The devices the engine's machine has, for choosing among them.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Devices {
    /// Audio inputs sound triggers can listen to, with a label to show.
    pub audio_inputs: Vec<(AudioDeviceId, String)>,
    pub midi_outputs: Vec<String>,
    /// Serial numbers of the connected Enttec DMX USB Pro interfaces.
    pub enttec: Vec<String>,
}

/// Highest selectable output frame rate. Choosing it means unlimited.
pub const FPS_LIMIT_MAX: f32 = 333.0;

/// Settings of the machine running the engine, which any interface may change.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct ServerSettings {
    /// The render and output frame rate limit; `FPS_LIMIT_MAX` and above
    /// mean unlimited.
    pub fps_limit: f32,
    /// Follow the Ableton Link tempo without changing it.
    pub ableton_link_read_only: bool,
    /// The asset repository's remote.
    pub git_url: String,
    /// The SSH key for the repository, a path on the engine's machine.
    pub git_private_key: Option<String>,
    pub git_use_passphrase: bool,
}

/// Work on the asset repository an interface asks for.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum StorageCommand {
    Commit {
        message: String,
    },
    Pull,
    Push,
    SwitchBranch(String),
    /// Deletes the local repository and clones it again from the git URL.
    Reclone,
    /// The passphrase of the SSH key, kept in the engine machine's keyring.
    SetGitPassphrase(String),
}

/// The engine's state as user interfaces see it, published every tick.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct EngineState {
    /// The engine's project, shared: the engine publishes a new `Arc` only
    /// when the project changed, so an unchanged one is the same `Arc` and
    /// compares in O(1). It goes over the wire as a plain project.
    pub project: Option<Arc<Project>>,
    pub project_id: Option<AssetId<Project>>,
    pub selected_scene_instance: GridLocation,
    pub blackout: bool,
    pub blackout_hold: bool,
    pub midi_output_active: bool,
    pub output_config: OutputConfig,
    pub beat: BeatState,
    /// Taps counted for the tap button's indicator.
    pub tap_count: usize,
    /// Changes whenever the lamp positions changed: the patch or one of the
    /// fixture definitions it uses.
    pub layout_generation: u64,
    pub render: RenderStats,
    pub status: ServerStatus,
}

impl EngineState {
    /// A copy without the project, which goes as a patch.
    pub fn without_project(&self) -> Self {
        let Self {
            project: _,
            project_id,
            selected_scene_instance,
            blackout,
            blackout_hold,
            midi_output_active,
            output_config,
            beat,
            tap_count,
            layout_generation,
            render,
            status,
        } = self;
        Self {
            project: None,
            project_id: *project_id,
            selected_scene_instance: *selected_scene_instance,
            blackout: *blackout,
            blackout_hold: *blackout_hold,
            midi_output_active: *midi_output_active,
            output_config: output_config.clone(),
            beat: *beat,
            tap_count: *tap_count,
            layout_generation: *layout_generation,
            render: *render,
            status: status.clone(),
        }
    }

    /// Whether both differ only in their timing: the beat, which changes
    /// every tick and which remote interfaces extrapolate between updates, and
    /// the render stats.
    pub fn same_but_timing(&self, other: &Self) -> bool {
        self.same_but_timing_and_project(other)
            && match (self.project.as_deref(), other.project.as_deref()) {
                // The engine's unchanged project is the same value.
                (Some(a), Some(b)) => std::ptr::eq(a, b) || a == b,
                (a, b) => a.is_none() && b.is_none(),
            }
    }

    /// Whether both differ at most in their timing and their project, which
    /// an update carries on its own (`ProjectUpdate`).
    pub fn same_but_timing_and_project(&self, other: &Self) -> bool {
        let Self {
            project: _,
            project_id,
            selected_scene_instance,
            blackout,
            blackout_hold,
            midi_output_active,
            output_config,
            beat: _,
            tap_count,
            layout_generation,
            render: _,
            status,
        } = self;
        *project_id == other.project_id
            && *selected_scene_instance == other.selected_scene_instance
            && *blackout == other.blackout
            && *blackout_hold == other.blackout_hold
            && *midi_output_active == other.midi_output_active
            && *tap_count == other.tap_count
            && *layout_generation == other.layout_generation
            && *output_config == other.output_config
            && *status == other.status
    }
}

/// What a user interface sends the engine besides actions.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ClientInput {
    /// An action from the interface, numbered in the order it was sent.
    Action {
        sequence: u64,
        action: UiAction,
    },
    /// The keys held in the interface, for key inputs.
    Keys(BTreeSet<KeyCode>),
    /// The tempo set with the BPM slider.
    SetBeatsPerMinute(f32),
    /// Stores an asset in the collections and the repository.
    SaveAsset(AnyAsset),
    /// Removes an asset from the collections and the repository.
    DeleteAsset(AnyAssetId),
    Storage(StorageCommand),
    SetServerSettings(ServerSettings),
    /// The output routing the pointer is over, whose universe then blinks so
    /// the installation shows which lamps it drives. `None` when it left.
    HoverOutputRouting(Option<OutputRouting>),
    /// Whether this interface shows a device list, so the engine scans for
    /// devices.
    WatchDevices(bool),
    /// Whether this interface shows the MIDI monitor, so the engine streams
    /// the traffic.
    WatchMidi(bool),
    /// Whether this interface waits for MIDI learn input.
    MidiLearning(bool),
    MidiTest(TestCommand),
    /// The sound triggers this interface edits, whose spectra it needs.
    WatchSoundTriggers(BTreeSet<Uuid>),
}

/// Something the user interfaces have to react to once.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum EngineEvent {
    /// The code of this animation (all for `None`) changed: rebuild previews.
    ShaderReloaded(Option<AssetId<Animation>>),
    /// An error to show the user.
    Error(String),
}

/// The sound triggers' levels and the input spectrum, as interfaces need them
/// to draw effects that follow the music.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct SoundSnapshot {
    pub levels: BTreeMap<Uuid, f32>,
    /// The envelope input of every trigger, 0 to 1.
    pub impulses: BTreeMap<Uuid, f32>,
    /// `FREQ_BINS` little-endian `f32`s, as effects read them.
    pub spectrum: SharedBytes,
    /// The averaged spectrum of the triggers an interface edits, as
    /// little-endian `f32`s.
    pub trigger_spectra: BTreeMap<Uuid, SharedBytes>,
}

impl SoundSnapshot {
    /// What the trigger editor shows of a running trigger: impulse, level
    /// and its averaged spectrum (empty unless the trigger is watched).
    pub fn reading(&self, id: &Uuid) -> Option<(f32, f32, Vec<f32>)> {
        let level = *self.levels.get(id)?;
        let impulse = self.impulses.get(id).copied().unwrap_or_default();
        let spectrum = self
            .trigger_spectra
            .get(id)
            .map(|bytes| {
                bytes
                    .0
                    .as_chunks::<4>()
                    .0
                    .iter()
                    .map(|chunk| f32::from_le_bytes(*chunk))
                    .collect()
            })
            .unwrap_or_default();
        Some((impulse, level, spectrum))
    }
}

impl SoundLevels for SoundSnapshot {
    fn level(&self, id: &Uuid) -> f32 {
        self.levels.get(id).copied().unwrap_or(0.0)
    }

    fn spectrum_bytes(&self) -> [u8; FREQ_BINS * 4] {
        let mut bytes = [0; FREQ_BINS * 4];
        let len = self.spectrum.0.len().min(bytes.len());
        bytes[..len].copy_from_slice(&self.spectrum.0[..len]);
        bytes
    }
}

/// Where a thread's client inputs go.
type InputRoute = Box<dyn Fn(ClientInput)>;

thread_local! {
    /// Where client inputs enqueued on this thread go: the interface's
    /// connection to the engine.
    static INPUT_ROUTE: std::cell::RefCell<Option<InputRoute>> =
        const { std::cell::RefCell::new(None) };
}

/// Sends every client input enqueued on the calling thread to `route`.
pub fn route_inputs_on_this_thread(route: impl Fn(ClientInput) + 'static) {
    INPUT_ROUTE.with(|cell| *cell.borrow_mut() = Some(Box::new(route)));
}

impl ClientInput {
    /// Sends this input to the engine through the calling thread's route.
    pub fn enqueue(self) {
        INPUT_ROUTE.with(|cell| match cell.borrow().as_ref() {
            Some(route) => route(self),
            None => tracing::warn!("Dropped {self:?}: this thread has no connection to the engine"),
        });
    }
}