Skip to content

Commit 76ef846

Browse files
hifihedgehogclaude
andcommitted
Add MIDI virtual controller output type (Issue #7)
- MIDI slots are type-isolated: no switching between MIDI and game controller types - Sidebar/dashboard cards for MIDI show only a MIDI icon (no Xbox/DS4/VJoy buttons) - Game controller cards show only Xbox/DS4/VJoy buttons (no MIDI button) - Add controller popup has separate MIDI button alongside game controller types - MidiVirtualController: winmm P/Invoke, axes→CC, buttons→Note On/Off - MidiSlotConfig: port, channel, velocity, per-axis CC#, per-button note# - 16-bit trigger support (ushort, matching recent pipeline widening) - MIDI slots use schematic view, hide Sticks/Triggers/Force Feedback tabs - Full persistence via MidiSlotConfigData XML serialization Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent cc85a4c commit 76ef846

File tree

15 files changed

+965
-162
lines changed

15 files changed

+965
-162
lines changed

PadForge.App/Common/ControllerIcons.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,8 @@ public static class ControllerIcons
1212

1313
// Joystick icon from svgrepo.com (viewBox -4 -2 24 24, translated to 0,0 origin → 20x22 effective)
1414
public const string VJoySvgPath = "M11.915 15H13V9.874A4.002 4.002 0 0 1 14 2a4 4 0 0 1 1 7.874V15h2a3 3 0 0 1 3 3v4H4v-4a3 3 0 0 1 3-3h.085A1.5 1.5 0 0 1 8.5 14h2a1.5 1.5 0 0 1 1.415 1z";
15+
16+
// MIDI note icon (quarter note / beamed eighth notes, 24x24 viewBox)
17+
public const string MidiSvgPath = "M12 3v10.55A4 4 0 1 0 14 17V7h4V3h-6zM10 19a2 2 0 1 1 0-4 2 2 0 0 1 0 4z";
1518
}
1619
}

PadForge.App/Common/Input/InputManager.Step5.VirtualDevices.cs

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using System.Threading;
66
using Nefarius.ViGEm.Client;
77
using PadForge.Engine;
8+
using PadForge.ViewModels;
89

910
namespace PadForge.Common.Input
1011
{
@@ -45,6 +46,12 @@ public partial class InputManager
4546
/// </summary>
4647
internal bool[] SlotVJoyIsCustom { get; } = new bool[MaxPads];
4748

49+
/// <summary>
50+
/// Per-slot MIDI configuration snapshot. Written by InputService at 30Hz.
51+
/// Read by Step 5 to configure MIDI controllers on creation.
52+
/// </summary>
53+
internal MidiSlotConfig[] _midiConfigs = new MidiSlotConfig[MaxPads];
54+
4855
/// <summary>
4956
/// Count of currently active ViGEm virtual controllers.
5057
/// Used by IsViGEmVirtualDevice() in Step 1 for zero-VID/PID heuristic.
@@ -653,8 +660,10 @@ private IVirtualController CreateVirtualController(int padIndex)
653660
{
654661
var controllerType = SlotControllerTypes[padIndex];
655662

656-
// vJoy doesn't need ViGEm, but Xbox 360 and DS4 do.
657-
if (controllerType != VirtualControllerType.VJoy && _vigemClient == null)
663+
// vJoy and MIDI don't need ViGEm, but Xbox 360 and DS4 do.
664+
if (controllerType != VirtualControllerType.VJoy
665+
&& controllerType != VirtualControllerType.Midi
666+
&& _vigemClient == null)
658667
return null;
659668

660669
try
@@ -668,6 +677,7 @@ private IVirtualController CreateVirtualController(int padIndex)
668677
{
669678
VirtualControllerType.DualShock4 => new DS4VirtualController(_vigemClient),
670679
VirtualControllerType.VJoy => CreateVJoyController(),
680+
VirtualControllerType.Midi => CreateMidiController(padIndex),
671681
_ => new Xbox360VirtualController(_vigemClient)
672682
};
673683

@@ -736,6 +746,40 @@ private IVirtualController CreateVJoyController()
736746
return new VJoyVirtualController(deviceId);
737747
}
738748

749+
/// <summary>
750+
/// Creates a MIDI virtual controller for the given pad slot.
751+
/// Reads port name and config from the PadViewModel's MidiConfig.
752+
/// Returns null if the configured port is not found.
753+
/// </summary>
754+
private IVirtualController CreateMidiController(int padIndex)
755+
{
756+
var midiConfig = _midiConfigs[padIndex];
757+
if (midiConfig == null) return null;
758+
759+
var deviceId = MidiVirtualController.FindDeviceIdByName(midiConfig.PortName);
760+
if (deviceId == null)
761+
{
762+
// Auto-select the first available port if none configured or configured port missing.
763+
var ports = MidiVirtualController.GetOutputPortNames();
764+
if (ports.Length > 0)
765+
{
766+
midiConfig.PortName = ports[0];
767+
deviceId = MidiVirtualController.FindDeviceIdByName(midiConfig.PortName);
768+
}
769+
}
770+
if (deviceId == null)
771+
{
772+
RaiseError($"No MIDI output ports available. Install loopMIDI or connect a MIDI device.", null);
773+
return null;
774+
}
775+
776+
var vc = new MidiVirtualController(deviceId.Value, midiConfig.Channel - 1);
777+
vc.CcNumbers = midiConfig.GetCcNumbers();
778+
vc.NoteNumbers = midiConfig.GetNoteNumbers();
779+
vc.Velocity = midiConfig.Velocity;
780+
return vc;
781+
}
782+
739783
private void DestroyVirtualController(int padIndex)
740784
{
741785
var vc = _virtualControllers[padIndex];
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
using System;
2+
using System.Runtime.InteropServices;
3+
using PadForge.Engine;
4+
5+
namespace PadForge.Common.Input
6+
{
7+
/// <summary>
8+
/// Virtual controller that sends MIDI messages (CC for axes, Note On/Off for buttons)
9+
/// to a Windows MIDI output port (e.g., loopMIDI virtual port).
10+
/// Uses winmm P/Invoke — no external dependencies.
11+
/// </summary>
12+
internal sealed class MidiVirtualController : IVirtualController
13+
{
14+
// winmm P/Invoke
15+
[DllImport("winmm.dll")]
16+
private static extern int midiOutGetNumDevs();
17+
18+
[DllImport("winmm.dll", CharSet = CharSet.Unicode)]
19+
private static extern int midiOutGetDevCapsW(uint uDeviceID, ref MIDIOUTCAPS lpMidiOutCaps, int cbMidiOutCaps);
20+
21+
[DllImport("winmm.dll")]
22+
private static extern int midiOutOpen(out IntPtr lphmo, uint uDeviceID, IntPtr dwCallback, IntPtr dwCallbackInstance, int dwFlags);
23+
24+
[DllImport("winmm.dll")]
25+
private static extern int midiOutClose(IntPtr hmo);
26+
27+
[DllImport("winmm.dll")]
28+
private static extern int midiOutShortMsg(IntPtr hmo, uint dwMsg);
29+
30+
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
31+
private struct MIDIOUTCAPS
32+
{
33+
public ushort wMid;
34+
public ushort wPid;
35+
public uint vDriverVersion;
36+
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)]
37+
public string szPname;
38+
public ushort wTechnology;
39+
public ushort wVoices;
40+
public ushort wNotes;
41+
public ushort wChannelMask;
42+
public uint dwSupport;
43+
}
44+
45+
// MIDI status bytes
46+
private const byte NoteOff = 0x80;
47+
private const byte NoteOn = 0x90;
48+
private const byte ControlChange = 0xB0;
49+
50+
private IntPtr _handle;
51+
private readonly uint _deviceId;
52+
private readonly int _channel; // 0-15
53+
private bool _connected;
54+
private bool _disposed;
55+
56+
// Change detection — only send messages when values actually change.
57+
private readonly byte[] _lastCcValues = new byte[6]; // LX, LY, LT, RX, RY, RT
58+
private ushort _lastButtons;
59+
60+
// Default CC mappings: LX=1, LY=2, LT=3, RX=4, RY=5, RT=6
61+
// These can be customized via MidiSlotConfig.
62+
internal int[] CcNumbers { get; set; } = { 1, 2, 3, 4, 5, 6 };
63+
64+
// Default note mappings: A=60, B=61, X=62, Y=63, LB=64, RB=65, Back=66, Start=67, LS=68, RS=69, Guide=70
65+
internal int[] NoteNumbers { get; set; } = { 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70 };
66+
67+
// Note velocity for button presses.
68+
internal byte Velocity { get; set; } = 127;
69+
70+
public VirtualControllerType Type => VirtualControllerType.Midi;
71+
public bool IsConnected => _connected;
72+
73+
public MidiVirtualController(uint deviceId, int channel)
74+
{
75+
_deviceId = deviceId;
76+
_channel = Math.Clamp(channel, 0, 15);
77+
}
78+
79+
public void Connect()
80+
{
81+
if (_connected) return;
82+
int result = midiOutOpen(out _handle, _deviceId, IntPtr.Zero, IntPtr.Zero, 0);
83+
if (result != 0)
84+
throw new InvalidOperationException($"midiOutOpen failed with error {result} for device {_deviceId}");
85+
_connected = true;
86+
87+
// Initialize change detection to neutral values.
88+
for (int i = 0; i < _lastCcValues.Length; i++)
89+
_lastCcValues[i] = 64; // center for axes
90+
_lastButtons = 0;
91+
}
92+
93+
public void Disconnect()
94+
{
95+
if (!_connected) return;
96+
_connected = false;
97+
98+
// Send Note Off for any held buttons.
99+
for (int i = 0; i < 11 && i < NoteNumbers.Length; i++)
100+
{
101+
if ((_lastButtons & (1 << i)) != 0)
102+
SendNoteOff(NoteNumbers[i]);
103+
}
104+
_lastButtons = 0;
105+
106+
if (_handle != IntPtr.Zero)
107+
{
108+
midiOutClose(_handle);
109+
_handle = IntPtr.Zero;
110+
}
111+
}
112+
113+
public void SubmitGamepadState(Gamepad gp)
114+
{
115+
if (!_connected || _handle == IntPtr.Zero) return;
116+
117+
// Axes → CC messages (convert to 0-127 range)
118+
// Stick axes: -32768..32767 → 0..127
119+
// Trigger axes: 0..65535 → 0..127
120+
byte lx = AxisToMidi(gp.ThumbLX);
121+
byte ly = AxisToMidi(gp.ThumbLY);
122+
byte lt = TriggerToMidi(gp.LeftTrigger);
123+
byte rx = AxisToMidi(gp.ThumbRX);
124+
byte ry = AxisToMidi(gp.ThumbRY);
125+
byte rt = TriggerToMidi(gp.RightTrigger);
126+
127+
byte[] ccValues = { lx, ly, lt, rx, ry, rt };
128+
for (int i = 0; i < 6 && i < CcNumbers.Length; i++)
129+
{
130+
if (ccValues[i] != _lastCcValues[i])
131+
{
132+
SendCC(CcNumbers[i], ccValues[i]);
133+
_lastCcValues[i] = ccValues[i];
134+
}
135+
}
136+
137+
// Buttons → Note On/Off
138+
ushort buttons = gp.Buttons;
139+
ushort changed = (ushort)(buttons ^ _lastButtons);
140+
for (int i = 0; i < 11 && i < NoteNumbers.Length; i++)
141+
{
142+
ushort mask = (ushort)(1 << i);
143+
if ((changed & mask) == 0) continue;
144+
145+
if ((buttons & mask) != 0)
146+
SendNoteOn(NoteNumbers[i], Velocity);
147+
else
148+
SendNoteOff(NoteNumbers[i]);
149+
}
150+
_lastButtons = buttons;
151+
}
152+
153+
public void RegisterFeedbackCallback(int padIndex, Vibration[] vibrationStates)
154+
{
155+
// MIDI has no rumble feedback — no-op.
156+
}
157+
158+
public void Dispose()
159+
{
160+
if (_disposed) return;
161+
_disposed = true;
162+
Disconnect();
163+
}
164+
165+
// ─────────────────────────────────────────────
166+
// MIDI message helpers
167+
// ─────────────────────────────────────────────
168+
169+
private void SendCC(int ccNumber, byte value)
170+
{
171+
// CC message: [status | channel] [cc#] [value]
172+
uint msg = (uint)((ControlChange | _channel) | (ccNumber << 8) | (value << 16));
173+
midiOutShortMsg(_handle, msg);
174+
}
175+
176+
private void SendNoteOn(int note, byte velocity)
177+
{
178+
uint msg = (uint)((NoteOn | _channel) | (note << 8) | (velocity << 16));
179+
midiOutShortMsg(_handle, msg);
180+
}
181+
182+
private void SendNoteOff(int note)
183+
{
184+
uint msg = (uint)((NoteOff | _channel) | (note << 8));
185+
midiOutShortMsg(_handle, msg);
186+
}
187+
188+
// ─────────────────────────────────────────────
189+
// Value conversion
190+
// ─────────────────────────────────────────────
191+
192+
private static byte AxisToMidi(short value)
193+
{
194+
// -32768..32767 → 0..127
195+
return (byte)((value + 32768) * 127 / 65535);
196+
}
197+
198+
private static byte TriggerToMidi(ushort value)
199+
{
200+
// 0..65535 → 0..127
201+
return (byte)(value * 127 / 65535);
202+
}
203+
204+
// ─────────────────────────────────────────────
205+
// Static helpers for port enumeration
206+
// ─────────────────────────────────────────────
207+
208+
/// <summary>
209+
/// Returns available MIDI output port names.
210+
/// </summary>
211+
public static string[] GetOutputPortNames()
212+
{
213+
int count = midiOutGetNumDevs();
214+
var names = new string[count];
215+
for (uint i = 0; i < count; i++)
216+
{
217+
var caps = new MIDIOUTCAPS();
218+
if (midiOutGetDevCapsW(i, ref caps, Marshal.SizeOf<MIDIOUTCAPS>()) == 0)
219+
names[i] = caps.szPname;
220+
else
221+
names[i] = $"MIDI Out {i}";
222+
}
223+
return names;
224+
}
225+
226+
/// <summary>
227+
/// Finds the device ID for a port by name. Returns null if not found.
228+
/// </summary>
229+
public static uint? FindDeviceIdByName(string portName)
230+
{
231+
if (string.IsNullOrEmpty(portName)) return null;
232+
int count = midiOutGetNumDevs();
233+
for (uint i = 0; i < count; i++)
234+
{
235+
var caps = new MIDIOUTCAPS();
236+
if (midiOutGetDevCapsW(i, ref caps, Marshal.SizeOf<MIDIOUTCAPS>()) == 0
237+
&& string.Equals(caps.szPname, portName, StringComparison.OrdinalIgnoreCase))
238+
return i;
239+
}
240+
return null;
241+
}
242+
}
243+
}

PadForge.App/Common/SettingsManager.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ public static partial class SettingsManager
5757
/// <summary>Maximum number of vJoy virtual controllers (vJoy driver limit).</summary>
5858
public const int MaxVJoySlots = 16;
5959

60+
/// <summary>Maximum number of MIDI virtual controllers.</summary>
61+
public const int MaxMidiSlots = InputManager.MaxPads;
62+
6063
/// <summary>Whether each slot has been explicitly created. Persisted to settings.</summary>
6164
public static bool[] SlotCreated { get; set; } = new bool[InputManager.MaxPads];
6265

0 commit comments

Comments
 (0)