Files
2026-07-13 13:13:17 +08:00

626 lines
27 KiB
C#

using NAudio;
using NAudio.Midi;
using T3.Editor.Gui.Interaction.Midi.CommandProcessing;
using T3.Editor.Gui.Interaction.Variations;
using T3.Editor.Gui.Interaction.Variations.Model;
using T3.Editor.Gui.UiHelpers;
namespace T3.Editor.Gui.Interaction.Midi.CompatibleDevices;
// ReSharper disable InconsistentNaming, UnusedMember.Local, CommentTypo, StringLiteralTypo
/// <summary>
/// MIDI controller implementation for the Akai APC40 (original/Mk1).
///
/// The APC40 uses a simpler LED control scheme compared to Mk2 with only
/// 7 color states (off, green, green blinking, red, red blinking, orange, orange blinking).
///
/// The device is initialized to "Generic" mode (0x40) which allows basic LED control.
/// </summary>
[MidiDeviceProduct("Akai APC40")]
public sealed class Apc40Mk1 : CompatibleMidiDevice
{
public Apc40Mk1()
{
CommandTriggerCombinations =
[
// Snapshot activate/create - press clip button to activate or create snapshot
new CommandTriggerCombination(SnapshotActions.ActivateOrCreateSnapshotAtIndex, InputModes.Default, [SceneTrigger1To40],
CommandTriggerCombination.ExecutesAt.SingleRangeButtonPressed),
// Snapshot save - hold Shift + press clip button to save
new CommandTriggerCombination(SnapshotActions.SaveSnapshotAtIndex, InputModes.Save, [SceneTrigger1To40],
CommandTriggerCombination.ExecutesAt.SingleRangeButtonPressed),
// Snapshot delete - hold Scene Launch 1 + press clip button to delete
new CommandTriggerCombination(SnapshotActions.RemoveSnapshotAtIndex, InputModes.Delete, [SceneTrigger1To40],
CommandTriggerCombination.ExecutesAt.SingleRangeButtonPressed),
// Blend between two snapshots - press two clip buttons simultaneously
new CommandTriggerCombination(BlendActions.StartBlendingSnapshots, InputModes.Default, [SceneTrigger1To40],
CommandTriggerCombination.ExecutesAt.AllCombinedButtonsReleased),
// Start blend towards - hold Scene Launch 2 + press clip button to start blend
new CommandTriggerCombination(BlendActions.StartBlendingTowardsSnapshot, InputModes.BlendTo, [SceneTrigger1To40],
CommandTriggerCombination.ExecutesAt.SingleRangeButtonPressed),
// Stop blending - press Stop All Clips to stop blend operation
new CommandTriggerCombination(BlendActions.StopBlendingTowards, InputModes.Default, [ClipStopAll],
CommandTriggerCombination.ExecutesAt.SingleActionButtonPressed),
// Update blend progress with crossfader
new CommandTriggerCombination(BlendActions.UpdateBlendingTowardsProgress, InputModes.Default, [AbFader],
CommandTriggerCombination.ExecutesAt.ControllerChange),
// Update blend progress with Master Fader
new CommandTriggerCombination(BlendActions.UpdateBlendingTowardsProgress, InputModes.Default, [MasterFader],
CommandTriggerCombination.ExecutesAt.ControllerChange),
// Update blend values with channel faders
new CommandTriggerCombination(BlendActions.UpdateBlendValues, InputModes.Default, [Fader1To8],
CommandTriggerCombination.ExecutesAt.ControllerChange),
// Mode switching - Shift + Record/Arm 1/2/3 to switch between modes
// Record/Arm 1 = Generic passthrough (0x40), Record/Arm 2 = Ableton passthrough (0x41), Record/Arm 3 = Ableton control (0x41)
new CommandTriggerCombination(HandleModeSwitch, InputModes.Save, [RecordArmButtons],
CommandTriggerCombination.ExecutesAt.SingleRangeButtonPressed)
];
ModeButtons =
[
new ModeButton(SceneLaunch1, InputModes.Delete),
new ModeButton(SceneLaunch2, InputModes.BlendTo),
new ModeButton(Shift, InputModes.Save)
];
}
/// <summary>
/// Handles mode switching based on which Record/Arm button was pressed (with Shift held).
/// Index 0 = Generic passthrough (0x40), Index 1 = Ableton passthrough (0x41), Index 2 = Ableton control (0x41)
/// </summary>
private void HandleModeSwitch(int index)
{
switch (index)
{
case 0: // Record/Arm 1 - Generic passthrough mode (0x40)
LogMidiDebug("APC40 Mk1: Setting GENERIC PASSTHROUGH mode (0x40)");
_useGenericMode = true;
SendModeInitSysEx();
SetControlMode(false);
break;
case 1: // Record/Arm 2 - Ableton passthrough mode (0x41)
LogMidiDebug("APC40 Mk1: Setting ABLETON PASSTHROUGH mode (0x41)");
_useGenericMode = false;
SendModeInitSysEx();
SetControlMode(false);
break;
case 2: // Record/Arm 3 - Ableton control mode (0x41)
LogMidiDebug("APC40 Mk1: Setting ABLETON CONTROL mode (0x41)");
_useGenericMode = false;
SendModeInitSysEx();
SetControlMode(true);
break;
default:
LogMidiDebug($"APC40 Mk1: Ignoring mode switch for index {index}");
return; // Don't clear signals for invalid index
}
// Clear button signals after mode switch to prevent stale signals from
// blocking subsequent mode switches. The button mapping changes between
// Generic and Ableton modes, so old signals may not match new button IDs.
ClearButtonSignals();
}
/// <summary>
/// Sends the SysEx initialization message to set the APC40 mode.
/// Uses 0x40 (Generic) or 0x41 (Ableton Live) based on _useGenericMode flag.
/// </summary>
private void SendModeInitSysEx()
{
if (MidiOutConnection == null)
return;
// Clear all LEDs BEFORE mode switch
// This clears using BOTH mode mappings to ensure all LEDs are off
ClearAllLedsRaw();
var modeIdentifier = _useGenericMode ? (byte)0x40 : (byte)0x41;
LogMidiDebug($"APC40 Mk1: Sending mode SysEx (0x{modeIdentifier:X2})...");
var buffer = new byte[]
{
0xF0, // MIDI exclusive start
0x47, // Manufacturers ID Byte (Akai)
0x00, // System Exclusive Device ID
0x73, // Product model ID (APC40)
0x60, // Message type identifier (Introduction message)
0x00, // Number of data bytes to follow (most significant)
0x04, // Number of data bytes to follow (least significant) = 4 bytes
modeIdentifier, // Application/Configuration Identifier (0x40=Generic, 0x41=Ableton Live mode)
0x08, // PC application Software version major
0x01, // PC application Software version minor
0x01, // PC application Software bug-fix level
0xF7 // MIDI exclusive end
};
try
{
MidiOutConnection.SendBuffer(buffer);
_initialized = true;
LogMidiDebug($"APC40 Mk1: Mode switch complete (0x{modeIdentifier:X2})");
}
catch (Exception e)
{
Log.Warning($"APC40 Mk1: Failed to send mode SysEx: {e.Message}");
}
// Only update the mode indicator LED (Record/Arm 1, 2, or 3)
// Don't update any other LEDs - let the normal update cycle handle that
UpdateRecordArmModeLeds();
}
/// <summary>
/// Clears all LEDs on the device by sending direct MIDI messages.
/// Bypasses cache and clears using BOTH Generic and Ableton mode mappings.
/// </summary>
private void ClearAllLedsRaw()
{
if (MidiOutConnection == null)
return;
Array.Fill(CacheControllerColors, -1);
// Clear clip grid - Generic mode (Notes 0-39 on Channel 1)
foreach (var note in GenericClipGridNotes.Indices())
SendNoteRaw(MidiChannels1To8.StartIndex, note, 0);
// Clear clip grid - Ableton mode (Notes 53-57 on Channels 1-8)
foreach (var note in AbletonClipGridNotes.Indices())
foreach (var ch in MidiChannels1To8.Indices())
SendNoteRaw(ch, note, 0);
// Clear scene launch LEDs
foreach (var note in SceneLaunchNotes.Indices())
SendNoteRaw(MidiChannels1To8.StartIndex, note, 0);
// Clear Record/Arm LEDs - Generic mode (Notes 48-55 on Channel 1)
foreach (var note in GenericRecordArmNotes.Indices())
SendNoteRaw(MidiChannels1To8.StartIndex, note, 0);
// Clear Record/Arm LEDs - Ableton mode (Note 48 on Channels 1-8)
foreach (var ch in MidiChannels1To8.Indices())
SendNoteRaw(ch, AbletonRecordArmNote, 0);
}
/// <summary>
/// Sends a raw MIDI note, bypassing cache. Used for LED clearing.
/// </summary>
private void SendNoteRaw(int channel, int note, int velocity)
{
try
{
var evt = new NoteOnEvent(0, channel, note, velocity, 0);
MidiOutConnection?.Send(evt.GetAsShortMessage());
}
catch (Exception e)
{
Log.Warning($"Failed to send MIDI note (ch={channel}, note={note}): {e.Message}");
}
}
/// <summary>
/// Called when control mode changes. Reinitialize device when entering control mode.
/// </summary>
protected override void OnControlModeChanged(bool isNowInControlMode)
{
base.OnControlModeChanged(isNowInControlMode);
if (isNowInControlMode)
{
// Re-entering control mode - reinitialize the device
_initialized = false;
}
// Update Record/Arm LEDs to show current mode
UpdateRecordArmModeLeds();
}
/// <summary>
/// Updates Record/Arm 1, 2, and 3 LEDs to show current mode.
/// Green = active mode, Off = inactive
/// </summary>
private void UpdateRecordArmModeLeds()
{
if (MidiOutConnection == null)
return;
var modeLedColors = new[]
{
_useGenericMode ? Apc40Mk1Colors.Green : Apc40Mk1Colors.Off, // Generic passthrough
!_useGenericMode && !IsInControlMode ? Apc40Mk1Colors.Green : Apc40Mk1Colors.Off, // Ableton passthrough
!_useGenericMode && IsInControlMode ? Apc40Mk1Colors.Green : Apc40Mk1Colors.Off // Ableton control
};
for (var i = 0; i < modeLedColors.Length; i++)
{
var channel = _useGenericMode ? MidiChannels1To8.StartIndex : MidiChannels1To8.StartIndex + i;
var note = _useGenericMode ? GenericRecordArmNotes.StartIndex + i : AbletonRecordArmNote;
SendNoteRaw(channel, note, (int)modeLedColors[i]);
}
}
/// <summary>
/// Clears all LEDs when in passthrough mode. Shows mode highlighting when Shift is held.
/// </summary>
protected override void ClearDeviceLeds()
{
if (MidiOutConnection == null)
return;
_updateCount++;
// Clear clip launch grid LEDs or show mode highlight
for (var i = 0; i < ClipGridSize; i++)
{
if (ActiveMode != InputModes.Default)
CacheControllerColors[i] = -1;
SendColor(MidiOutConnection, i, AddModeHighlight(i, (int)Apc40Mk1Colors.Off));
}
// Clear scene launch LEDs
foreach (var i in SceneLaunchNotes.Indices())
{
CacheControllerColors[i] = -1;
SendColor(MidiOutConnection, i, (int)Apc40Mk1Colors.Off);
}
}
protected override void UpdateVariationVisualization()
{
_updateCount++;
if (!_initialized)
SendModeInitSysEx();
// Update clip launch button LEDs (5x8 grid)
UpdateRangeLeds(SceneTrigger1To40, mappedIndex =>
{
if (!SymbolVariationPool.TryGetSnapshot(mappedIndex, out var v))
return AddModeHighlight(mappedIndex, (int)Apc40Mk1Colors.Off);
var isBlendTarget = BlendActions.BlendTowardsIndex == mappedIndex;
var color = v.State switch
{
Variation.States.Active => Apc40Mk1Colors.Red,
Variation.States.Modified => Apc40Mk1Colors.Orange,
Variation.States.IsBlended => Apc40Mk1Colors.OrangeBlinking,
Variation.States.InActive => isBlendTarget ? Apc40Mk1Colors.OrangeBlinking : Apc40Mk1Colors.Green,
_ => Apc40Mk1Colors.Off
};
return AddModeHighlight(mappedIndex, (int)color);
});
// Update scene launch LEDs only in Ableton control mode
if (IsInControlMode && !_useGenericMode)
UpdateSceneLaunchLeds();
}
/// <summary>
/// Updates the scene launch button LEDs to indicate current input mode.
/// </summary>
private void UpdateSceneLaunchLeds()
{
if (MidiOutConnection == null)
return;
var colors = new[]
{
ActiveMode == InputModes.Delete ? Apc40Mk1Colors.RedBlinking : Apc40Mk1Colors.Red,
ActiveMode == InputModes.BlendTo ? Apc40Mk1Colors.OrangeBlinking : Apc40Mk1Colors.Orange,
Apc40Mk1Colors.Off,
Apc40Mk1Colors.Off,
Apc40Mk1Colors.Off
};
for (var i = 0; i < colors.Length; i++)
SendColor(MidiOutConnection, SceneLaunchNotes.StartIndex + i, (int)colors[i]);
}
private int AddModeHighlight(int index, int orgColor)
{
// Software-based flashing using solid colors
var indicatedStatus = (_updateCount + index / AbletonClipGridColumns) % 30 < 4;
if (!indicatedStatus)
{
return orgColor;
}
return ActiveMode switch
{
InputModes.Save => (int)Apc40Mk1Colors.Green,
InputModes.BlendTo => (int)Apc40Mk1Colors.Orange,
InputModes.Delete => (int)Apc40Mk1Colors.Red,
_ => orgColor
};
}
/// <summary>
/// Sends LED color using APC40 Mk1 specific channel mapping.
/// Generic mode: Notes 0-39 on Channel 1. Ableton mode: Notes 53-57 on Channels 1-8.
/// </summary>
protected override void SendColor(MidiOut midiOut, int apcControlIndex, int colorCode)
{
if (apcControlIndex < 0 || apcControlIndex >= CacheControllerColors.Length)
return;
if (CacheControllerColors[apcControlIndex] == colorCode)
return;
int channel, noteNumber;
if (apcControlIndex < ClipGridSize)
{
if (_useGenericMode)
{
channel = MidiChannels1To8.StartIndex;
noteNumber = apcControlIndex;
}
else
{
var row = apcControlIndex / AbletonClipGridColumns;
var col = apcControlIndex % AbletonClipGridColumns;
noteNumber = row + AbletonClipGridNotes.StartIndex;
channel = col + MidiChannels1To8.StartIndex;
}
}
else
{
channel = MidiChannels1To8.StartIndex;
noteNumber = apcControlIndex;
}
try
{
var noteOnEvent = new NoteOnEvent(0, channel, noteNumber, colorCode, 0);
midiOut.Send(noteOnEvent.GetAsShortMessage());
CacheControllerColors[apcControlIndex] = colorCode;
}
catch (MmException e)
{
Log.Warning($"Failed setting midi color for index {apcControlIndex}: {e.Message}");
}
}
/// <summary>
/// Converts APC40 Mk1 MIDI channel/note to button index.
/// Mapping differs between Generic Mode (0x40) and Ableton Live Mode (0x41).
/// </summary>
protected override int ConvertNoteToButtonId(int channel, int noteNumber)
{
// Shift button - same in both modes
if (noteNumber == ShiftButtonNote && channel == MidiChannels1To8.StartIndex)
{
LogMidiDebug($"ConvertNoteToButtonId: Shift button Note={noteNumber}, Channel={channel} -> ButtonId={ShiftButtonNote}");
return ShiftButtonNote;
}
// Clip launch grid
if (_useGenericMode)
{
// Generic: Notes 0-39 on Channel 1
if (GenericClipGridNotes.IncludesButtonIndex(noteNumber) && channel == MidiChannels1To8.StartIndex)
{
LogMidiDebug($"ConvertNoteToButtonId [Generic]: Clip grid Note={noteNumber}, Channel={channel} -> ButtonId={noteNumber}");
return noteNumber;
}
}
else
{
// Ableton: Notes 53-57 on Channels 1-8 (5x8 grid)
if (AbletonClipGridNotes.IncludesButtonIndex(noteNumber) && MidiChannels1To8.IncludesButtonIndex(channel))
{
var row = AbletonClipGridNotes.GetMappedIndex(noteNumber);
var col = MidiChannels1To8.GetMappedIndex(channel);
var buttonId = row * AbletonClipGridColumns + col;
LogMidiDebug($"ConvertNoteToButtonId [Ableton]: Clip grid Note={noteNumber}, Channel={channel} -> row={row}, col={col}, ButtonId={buttonId}");
return buttonId;
}
}
// Record/Arm buttons - must work in both modes for switching
if (noteNumber == AbletonRecordArmNote && MidiChannels1To8.IncludesButtonIndex(channel))
{
var buttonId = RecordArmBaseId + MidiChannels1To8.GetMappedIndex(channel);
LogMidiDebug($"ConvertNoteToButtonId: Record/Arm (Ableton mapping) Note={noteNumber}, Channel={channel} -> ButtonId={buttonId}");
return buttonId;
}
switch (_useGenericMode)
{
case true when channel == MidiChannels1To8.StartIndex:
{
// Generic Record/Arm: Notes 49-55 on Channel 1 (48 handled above)
if (GenericRecordArmNotes.IncludesButtonIndex(noteNumber) && noteNumber != AbletonRecordArmNote)
{
var buttonId = RecordArmBaseId + GenericRecordArmNotes.GetMappedIndex(noteNumber);
LogMidiDebug($"ConvertNoteToButtonId: Record/Arm (Generic mapping) Note={noteNumber}, Channel={channel} -> ButtonId={buttonId}");
return buttonId;
}
// Generic Track Select: Notes 58-65 on Channel 1
if (GenericTrackSelectNotes.IncludesButtonIndex(noteNumber))
{
var buttonId = TrackSelectBaseId + GenericTrackSelectNotes.GetMappedIndex(noteNumber);
LogMidiDebug($"ConvertNoteToButtonId: Track Select (Generic mapping) Note={noteNumber}, Channel={channel} -> ButtonId={buttonId}");
return buttonId;
}
break;
}
// Ableton Track Select: Note 51 on Channels 1-8
case false when noteNumber == AbletonTrackSelectNote && MidiChannels1To8.IncludesButtonIndex(channel):
{
var buttonId = TrackSelectBaseId + MidiChannels1To8.GetMappedIndex(channel);
LogMidiDebug($"ConvertNoteToButtonId: Track Select (Ableton mapping) Note={noteNumber}, Channel={channel} -> ButtonId={buttonId}");
return buttonId;
}
}
// Default fallback - use note number directly
return noteNumber;
}
// Base ID for track select buttons to avoid collision with other button IDs
private const int TrackSelectBaseId = 1000;
#region MIDI Note/Channel Mapping Constants
// ===== Common to both modes =====
private const int ShiftButtonNote = 98;
private const int ClipGridSize = 40; // 5 rows x 8 columns
// ===== Channel range for multi-channel mappings =====
private static readonly ButtonRange MidiChannels1To8 = new(1, 8);
// ===== Generic Mode (0x40) MIDI Mappings =====
// Clip Launch Grid: Notes 0-39 on Channel 1
private static readonly ButtonRange GenericClipGridNotes = new(0, 39);
// Record/Arm: Notes 48-55 on Channel 1
private static readonly ButtonRange GenericRecordArmNotes = new(48, 55);
// Track Select: Notes 58-65 on Channel 1
private static readonly ButtonRange GenericTrackSelectNotes = new(58, 65);
// ===== Ableton Live Mode (0x41) MIDI Mappings =====
// Clip Launch Grid: Notes 53-57 on Channels 1-8 (5 rows x 8 columns)
private static readonly ButtonRange AbletonClipGridNotes = new(53, 57);
private const int AbletonClipGridColumns = 8;
// Record/Arm: Note 48 on Channels 1-8
private const int AbletonRecordArmNote = 48;
// Track Select: Note 51 on Channels 1-8
private const int AbletonTrackSelectNote = 51;
// ===== Scene Launch buttons (same in both modes) =====
private static readonly ButtonRange SceneLaunchNotes = new(82, 86);
#endregion
private int _updateCount;
// APC40 Mk1 Clip Launch Button Grid (8 columns x 5 rows = 40 buttons)
// Notes 0-7 = Row 1, Notes 8-15 = Row 2, etc. (all on channel 1)
// The grid layout from bottom-left to top-right:
// Row 5: 32-39
// Row 4: 24-31
// Row 3: 16-23
// Row 2: 8-15
// Row 1: 0-7
private static readonly ButtonRange SceneTrigger1To40 = new(0, 39);
// Scene Launch buttons (right side of the grid)
private static readonly ButtonRange SceneLaunch1 = new(82);
private static readonly ButtonRange SceneLaunch2 = new(83);
private static readonly ButtonRange SceneLaunch3 = new(84);
private static readonly ButtonRange SceneLaunch4 = new(85);
private static readonly ButtonRange SceneLaunch5 = new(86);
// Track control buttons (below the clip grid)
private static readonly ButtonRange ClipStopButtons1To8 = new(52, 59);
private static readonly ButtonRange ClipSelectButtons1To8 = new(51, 51); // Note 51 with different channels
private static readonly ButtonRange ClipSoloButtons1To8 = new(50, 50); // Note 50 with different channels
private static readonly ButtonRange ClipRecArmButtons1To8 = new(48, 48); // Note 48 with different channels
private static readonly ButtonRange ClipABButtons1To8 = new(66, 73);
// Record/Arm buttons (bottom row - used for mode switching with Shift)
// In Ableton mode: Note 48 on Channels 1-8
// In Generic mode: Notes 50-57 on Channel 1
// Mapped to button IDs 2000-2007 via ConvertNoteToButtonId
private const int RecordArmBaseId = 2000;
private static readonly ButtonRange RecordArmButtons = new(RecordArmBaseId, RecordArmBaseId + 7);
// Track Select buttons (mapped to button IDs 1000-1007 via ConvertNoteToButtonId)
private static readonly ButtonRange TrackSelectButtons = new(TrackSelectBaseId, TrackSelectBaseId + 7);
// Stop all clips button
private static readonly ButtonRange ClipStopAll = new(81);
// Navigation buttons
private static readonly ButtonRange BankSelectUp = new(94);
private static readonly ButtonRange BankSelectDown = new(95);
private static readonly ButtonRange BankSelectRight = new(96);
private static readonly ButtonRange BankSelectLeft = new(97);
private static readonly ButtonRange Shift = new(98);
// Transport buttons
private static readonly ButtonRange TapTempo = new(99);
private static readonly ButtonRange NudgeMinus = new(100);
private static readonly ButtonRange NudgePlus = new(101);
private static readonly ButtonRange Session = new(102); // Also called "Clip/Track" on some models
// Device control buttons
private static readonly ButtonRange DeviceLeftArrow = new(58);
private static readonly ButtonRange DeviceRightArrow = new(59);
private static readonly ButtonRange BankLeftArrow = new(60);
private static readonly ButtonRange BankRightArrow = new(61);
private static readonly ButtonRange DevOnOff = new(62);
private static readonly ButtonRange DevLock = new(63);
private static readonly ButtonRange ClipDevView = new(64);
private static readonly ButtonRange DetailView = new(65);
// Mode buttons
private static readonly ButtonRange Pan = new(87);
private static readonly ButtonRange Sends = new(88);
private static readonly ButtonRange User = new(89);
private static readonly ButtonRange Metronome = new(90);
private static readonly ButtonRange Play = new(91);
private static readonly ButtonRange Stop = new(92);
private static readonly ButtonRange Record = new(93);
// Faders and knobs (Control Change messages)
private static readonly ButtonRange Fader1To8 = new(7, 7); // CC 7 on channels 1-8
private static readonly ButtonRange MasterFader = new(14); // CC 14 on channel 1
private static readonly ButtonRange AbFader = new(15); // CC 15 on channel 1 (Crossfader)
private static readonly ButtonRange TopKnobs1To8 = new(48, 55); // CC 48-55 on channel 1
private static readonly ButtonRange CueLevelKnob = new(47); // CC 47 on channel 1
private static readonly ButtonRange TempoKnob = new(13); // CC 13 on channel 1
private static readonly ButtonRange RightPerBankKnobs = new(16, 23); // CC 16-23 on channel 1
/// <summary>
/// APC40 Mk1 LED color values (sent as velocity in Note On messages)
/// </summary>
/// <remarks>
/// The APC40 Mk1 uses a simple 7-state LED system for the clip launch grid.
/// Reference: Akai APC40 Communications Protocol
/// </remarks>
private enum Apc40Mk1Colors
{
Off = 0,
Green = 1,
GreenBlinking = 2,
Red = 3,
RedBlinking = 4,
Orange = 5,
OrangeBlinking = 6
}
/// <summary>
/// Logs a debug message if MIDI debug logging is enabled in settings.
/// </summary>
private static void LogMidiDebug(string message)
{
if (UserSettings.Config.EnableMidiDebugLogging)
Log.Debug(message);
}
private bool _initialized;
private bool _useGenericMode = true; // Default to Generic passthrough mode (0x40)
}