using NAudio; using NAudio.Midi; using T3.IoServices; using T3.Editor.Gui.Interaction.Midi.CommandProcessing; namespace T3.Editor.Gui.Interaction.Midi; /// /// Combines midi signals related to Variations into triggers and invokes matching s. /// Allow to update the status of midi devices, e.g. for controlling LEDs to indicate available or active variations. /// /// /// This is NOT related to the MidiInput operator: Both are registered as independent /// and handle their events individually. /// public abstract class CompatibleMidiDevice : MidiConnectionManager.IMidiConsumer, IDisposable { internal void Initialize(MidiIn midiIn, MidiOut midiOut) { _midiInputConnection = midiIn; MidiOutConnection = midiOut; // Store the device name for control mode management var deviceInfo = MidiConnectionManager.GetDescriptionForMidiIn(midiIn); _deviceProductName = deviceInfo.ProductName; MidiConnectionManager.RegisterConsumer(this); } /// /// Sets whether this device should be in control mode (blocking passthrough) or passthrough mode. /// In control mode: MIDI messages are consumed by the compatible device and not passed to graph operators. /// In passthrough mode: MIDI messages are passed through to graph operators, editor controls are disabled. /// protected void SetControlMode(bool controlMode) { var wasInControlMode = IsInControlMode; IsInControlMode = controlMode; if (!string.IsNullOrEmpty(_deviceProductName)) { MidiConnectionManager.SetDeviceControlMode(_deviceProductName, controlMode); } // Notify derived classes when mode changes if (wasInControlMode != controlMode) { OnControlModeChanged(controlMode); } } /// /// Called when control mode changes. Override to reset LEDs or reinitialize device. /// protected virtual void OnControlModeChanged(bool isNowInControlMode) { if (!isNowInControlMode) { // Switching to passthrough mode - clear all cached LED colors so they get reset ClearLedCache(); } } /// /// Clears the cached LED colors. Called when switching to passthrough mode. /// private static void ClearLedCache() { for (var i = 0; i < CacheControllerColors.Length; i++) { CacheControllerColors[i] = -1; } } /// /// Gets whether this device is in control mode (blocking passthrough). /// protected bool IsInControlMode { get; private set; } = true; private string _deviceProductName; /// /// Depending on various hotkeys a device can be in different input modes. /// This allows actions with the button combinations. /// [Flags] public enum InputModes { Default = 1 << 1, Delete = 1 << 2, Save = 1 << 3, BlendTo = 1 << 4, None = 0, } protected InputModes ActiveMode = InputModes.Default; protected abstract void UpdateVariationVisualization(); /// /// Called when in passthrough mode to clear/reset device LEDs. /// Override in derived classes to send device-specific reset commands. /// protected virtual void ClearDeviceLeds() { } public void Update() { if (IsInControlMode) { // Control mode: normal operation - update LEDs and process all signals UpdateVariationVisualization(); } else { // Passthrough mode: clear LEDs and only process mode-switching signals ClearDeviceLeds(); } CombineButtonSignals(); ProcessLastSignals(); _hasNewMessages = false; } private void ProcessLastSignals() { if (!_hasNewMessages) return; ControlChangeSignal[] controlChangeSignals; lock (_controlSignalsSinceLastUpdate) { controlChangeSignals = _controlSignalsSinceLastUpdate.ToArray(); _controlSignalsSinceLastUpdate.Clear(); } // Only process control changes in control mode if (IsInControlMode && controlChangeSignals.Length != 0) { foreach (var ctc in CommandTriggerCombinations) { ctc.InvokeMatchingControlCommands(controlChangeSignals, ActiveMode); } } if (_combinedButtonSignals.Count == 0) return; // Log.Debug($"Processing {_combinedButtonSignals.Count} button signal(s), ActiveMode={ActiveMode}, ControlMode={_isInControlMode}"); var releasedMode = InputModes.None; // Mode buttons should ALWAYS be processed (even in passthrough mode) so user can switch back if (ModeButtons != null) { foreach (var modeButton in ModeButtons) { var matchingSignal = _combinedButtonSignals.Values.SingleOrDefault(s => modeButton.ButtonRange.IncludesButtonIndex(s.ButtonId)); if (matchingSignal == null) continue; if (matchingSignal.State == ButtonSignal.States.JustPressed) { if (ActiveMode == InputModes.Default) { // Log.Debug($"Mode changed to {modeButton.Mode} (button {matchingSignal.ButtonId})"); ActiveMode = modeButton.Mode; } } else if (matchingSignal.State == ButtonSignal.States.Released && ActiveMode == modeButton.Mode) { // Log.Debug($"Mode released from {modeButton.Mode} back to Default"); releasedMode = modeButton.Mode; ActiveMode = InputModes.Default; } } } if (CommandTriggerCombinations == null) return; var isAnyButtonPressed = _combinedButtonSignals.Values.Any(signal => (signal.State == ButtonSignal.States.JustPressed || signal.State == ButtonSignal.States.Hold)); foreach (var ctc in CommandTriggerCombinations) { // In passthrough, ignore all buttons except mode-switches (InputModes.Save / Shift). if (!IsInControlMode && ctc.RequiredInputMode != InputModes.Save) continue; ctc.InvokeMatchingButtonCommands(_combinedButtonSignals.Values.ToList(), ActiveMode, releasedMode); } if (!isAnyButtonPressed) { _combinedButtonSignals.Clear(); } } public void Dispose() { // Clear control mode when device is disposed so messages pass through again SetControlMode(false); MidiConnectionManager.UnregisterConsumer(this); } protected List CommandTriggerCombinations; protected List ModeButtons; private bool _hasNewMessages; // ------------------------------------------------------------------------------------ #region Process button Signals /// /// Combines press/hold/release signals into states like JustPressed and Hold than are /// later used to check for actions triggered by button combinations. /// private void CombineButtonSignals() { if (!_hasNewMessages) return; lock (_buttonSignalsSinceLastUpdate) { if (_buttonSignalsSinceLastUpdate.Count > 0) { // Log.Debug($"CombineButtonSignals: {_buttonSignalsSinceLastUpdate.Count} new signal(s) to process"); } foreach (var earlierSignal in _combinedButtonSignals.Values) { if (earlierSignal.State == ButtonSignal.States.JustPressed) earlierSignal.State = ButtonSignal.States.Hold; } foreach (var newSignal in _buttonSignalsSinceLastUpdate) { // Log.Debug($"CombineButtonSignals: Processing signal ButtonId={newSignal.ButtonId}, State={newSignal.State}"); if (_combinedButtonSignals.TryGetValue(newSignal.ButtonId, out var earlierSignal)) { earlierSignal.State = newSignal.State; } else { _combinedButtonSignals[newSignal.ButtonId] = newSignal; } } _buttonSignalsSinceLastUpdate.Clear(); } } void MidiConnectionManager.IMidiConsumer.OnSettingsChanged() { } void MidiConnectionManager.IMidiConsumer.MessageReceivedHandler(object sender, MidiInMessageEventArgs msg) { if (sender is not MidiIn midiIn || msg.MidiEvent == null) return; if (midiIn != _midiInputConnection) return; if (msg.MidiEvent == null) return; switch (msg.MidiEvent.CommandCode) { case MidiCommandCode.NoteOff: case MidiCommandCode.NoteOn: if (msg.MidiEvent is NoteEvent noteEvent) { var state = msg.MidiEvent.CommandCode == MidiCommandCode.NoteOn ? ButtonSignal.States.JustPressed : ButtonSignal.States.Released; // Allow device-specific mapping from channel/note to button ID var buttonId = ConvertNoteToButtonId(noteEvent.Channel, noteEvent.NoteNumber); // Log.Debug($"MIDI Button: Note={noteEvent.NoteNumber}, Channel={noteEvent.Channel}, ButtonId={buttonId}, Velocity={noteEvent.Velocity}, State={state}"); lock (_buttonSignalsSinceLastUpdate) { _buttonSignalsSinceLastUpdate.Add(new ButtonSignal() { Channel = noteEvent.Channel, ButtonId = buttonId, ControllerValue = noteEvent.Velocity, State = state, }); } _hasNewMessages = true; } return; case MidiCommandCode.ControlChange: if (msg.MidiEvent is not ControlChangeEvent controlChangeEvent) return; // Note: Debug log removed - too verbose for continuous controllers like crossfaders lock (_controlSignalsSinceLastUpdate) { _controlSignalsSinceLastUpdate.Add(new ControlChangeSignal() { Channel = controlChangeEvent.Channel, ControllerId = (int)controlChangeEvent.Controller, ControllerValue = controlChangeEvent.ControllerValue, }); } _hasNewMessages = true; return; } } /// /// Converts a MIDI channel and note number to a button ID. /// Override this in derived classes for devices that use channel-based button mapping. /// protected virtual int ConvertNoteToButtonId(int channel, int noteNumber) { // Default: just use the note number return noteNumber; } void MidiConnectionManager.IMidiConsumer.ErrorReceivedHandler(object sender, MidiInMessageEventArgs msg) { } #endregion //--------------------------------------------------------------------------------- #region SendColors protected delegate int ComputeColorForIndex(int index); protected void UpdateRangeLeds(ButtonRange range, ComputeColorForIndex computeColorForIndex) { if (MidiOutConnection == null) return; foreach (var buttonIndex in range.Indices()) { var mappedIndex = range.GetMappedIndex(buttonIndex); SendColor(MidiOutConnection, buttonIndex, computeColorForIndex(mappedIndex)); } } protected virtual void SendColor(MidiOut midiOut, int apcControlIndex, int colorCode) { if (CacheControllerColors[apcControlIndex] == colorCode) return; const int defaultChannel = 1; var noteOnEvent = new NoteOnEvent(0, defaultChannel, apcControlIndex, colorCode, 50); try { midiOut.Send(noteOnEvent.GetAsShortMessage()); } catch (MmException e) { Log.Warning("Failed setting midi color message:" + e.Message); } CacheControllerColors[apcControlIndex] = colorCode; } protected static readonly int[] CacheControllerColors = Enumerable.Repeat(-1, 256).ToArray(); #endregion /// /// Clears all pending button signals. Call this when a mode switch changes button mappings /// to prevent stale signals from blocking subsequent mode switches. /// protected void ClearButtonSignals() { _combinedButtonSignals.Clear(); lock (_buttonSignalsSinceLastUpdate) { _buttonSignalsSinceLastUpdate.Clear(); } } private readonly Dictionary _combinedButtonSignals = new(); private readonly List _buttonSignalsSinceLastUpdate = new(); private readonly List _controlSignalsSinceLastUpdate = new(); private MidiIn _midiInputConnection; protected MidiOut MidiOutConnection; } [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)] internal sealed class MidiDeviceProductAttribute : Attribute { internal MidiDeviceProductAttribute(string productName) { ProductNames = productName.Split(';'); } internal string[] ProductNames { get; } }