gled-ui 2.32.1

gled's egui interface, shared by the desktop app and the browser
Documentation
//! MIDI learn in the interface: while a binding waits for input, the messages
//! the engine captured are counted, and the binding takes the control that
//! was moved most.

use std::{collections::HashMap, time::Duration};

use gled_model::{
    asset::midi_controller::{MidiController, MidiTrigger},
    asset_id::AssetId,
};
use web_time::Instant;

#[derive(Debug, Clone, Copy)]
pub(crate) struct MidiLearnRequest {
    pub(crate) controller_id: AssetId<MidiController>,
    pub(crate) target: MidiLearnTarget,
}

#[derive(Debug, Clone)]
struct LearnSession {
    request: MidiLearnRequest,
    started_at: Instant,
    last_seen_at: Option<Instant>,
    stats: HashMap<(u8, u8), LearnStats>,
}

#[derive(Debug, Clone, Copy)]
struct LearnStats {
    count: u32,
    min_value: u8,
    max_value: u8,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum MidiLearnTarget {
    InputBinding(usize),
    OutputBinding(usize),
}

#[derive(Debug, Clone)]
pub(crate) struct MidiLearnCapture {
    pub(crate) controller_id: AssetId<MidiController>,
    pub(crate) target: MidiLearnTarget,
    pub(crate) trigger: MidiTrigger,
}

#[derive(Debug, Default)]
pub(crate) struct LearnState {
    /// Captured messages not yet looked at.
    incoming: Vec<[u8; 3]>,
    request: Option<LearnSession>,
    captured: Vec<MidiLearnCapture>,
}

impl LearnState {
    /// Forgets the waiting request when learning is no longer shown. The
    /// engine streams learn input only while an interface learns, see
    /// `set_learn_active`.
    pub(crate) fn set_streaming_enabled(&mut self, enabled: bool) {
        if !enabled {
            self.request = None;
        }
    }

    /// Takes a message the engine captured for learning.
    pub(crate) fn feed(&mut self, message: [u8; 3]) {
        self.incoming.push(message);
    }

    pub(crate) fn arm(&mut self, request: MidiLearnRequest) {
        self.request.replace(LearnSession {
            request,
            started_at: Instant::now(),
            last_seen_at: None,
            stats: HashMap::new(),
        });
    }

    pub(crate) fn active_request(&self) -> Option<MidiLearnRequest> {
        self.request.as_ref().map(|session| session.request)
    }

    pub(crate) fn flush_timeouts(&mut self) {
        self.poll_captures();

        let now = Instant::now();
        let Some(session) = self.request.as_ref() else {
            return;
        };

        if !should_finalize(session, now) {
            return;
        }

        if let Some(session) = self.request.take()
            && let Some(capture) = finalize_session(session)
        {
            self.captured.push(capture);
        }
    }

    fn poll_captures(&mut self) {
        for message in std::mem::take(&mut self.incoming) {
            let now = Instant::now();
            let mut maybe_finalized = None;

            let Some(session) = self.request.as_mut() else {
                continue;
            };

            let key = (message[0], message[1]);
            let value = message[2];
            session.last_seen_at = Some(now);
            session
                .stats
                .entry(key)
                .and_modify(|stats| {
                    stats.count += 1;
                    stats.min_value = stats.min_value.min(value);
                    stats.max_value = stats.max_value.max(value);
                })
                .or_insert(LearnStats {
                    count: 1,
                    min_value: value,
                    max_value: value,
                });

            if should_finalize(session, now) {
                maybe_finalized = self.request.take();
            }

            if let Some(session) = maybe_finalized
                && let Some(capture) = finalize_session(session)
            {
                self.captured.push(capture);
            }
        }
    }

    pub(crate) fn pop_capture(&mut self) -> Option<MidiLearnCapture> {
        self.captured.pop()
    }
}

fn should_finalize(session: &LearnSession, now: Instant) -> bool {
    const MIN_MESSAGES: u32 = 8;
    const QUIET_TIME: Duration = Duration::from_millis(180);
    const MAX_LEARN_TIME: Duration = Duration::from_secs(2);

    let total_messages: u32 = session.stats.values().map(|stats| stats.count).sum();
    if total_messages >= MIN_MESSAGES {
        return true;
    }

    if now.duration_since(session.started_at) >= MAX_LEARN_TIME && total_messages > 0 {
        return true;
    }

    if let Some(last_seen_at) = session.last_seen_at
        && now.duration_since(last_seen_at) >= QUIET_TIME
        && total_messages > 0
    {
        return true;
    }

    false
}

fn finalize_session(session: LearnSession) -> Option<MidiLearnCapture> {
    let (status, data1) = best_trigger(&session.stats)?;

    Some(MidiLearnCapture {
        controller_id: session.request.controller_id,
        target: session.request.target,
        trigger: MidiTrigger {
            status,
            data1,
            match_data1: match_data1_for_status(status),
        },
    })
}

fn best_trigger(stats: &HashMap<(u8, u8), LearnStats>) -> Option<(u8, u8)> {
    let mut canonical: HashMap<(u8, u8), (u32, u8)> = HashMap::new();

    for (&(status, data1), message_stats) in stats {
        let canonical_data1 = if status & 0xF0 == 0xB0 && (32..=63).contains(&data1) {
            data1 - 32
        } else {
            data1
        };

        let value_span = message_stats
            .max_value
            .saturating_sub(message_stats.min_value);
        canonical
            .entry((status, canonical_data1))
            .and_modify(|(count, span)| {
                *count += message_stats.count;
                *span = (*span).max(value_span);
            })
            .or_insert((message_stats.count, value_span));
    }

    canonical
        .into_iter()
        .max_by_key(|(_, (count, span))| ((*count as u64) << 8) + (*span as u64))
        .map(|(key, _)| key)
}

fn match_data1_for_status(status: u8) -> bool {
    !matches!(status & 0xF0, 0xC0 | 0xD0 | 0xE0)
}

/// What the monitor saw of one MIDI port.
#[derive(Clone, Debug, Default)]
pub(crate) struct MidiPortDiagnostics {
    pub(crate) port_name: String,
    pub(crate) input_connected: bool,
    pub(crate) output_connected: bool,
    pub(crate) input_connect_failed: u32,
    pub(crate) output_connect_failed: u32,
    pub(crate) message_count: u64,
}