diff --git a/Directory.Packages.props b/Directory.Packages.props index 9e3e9c97d..090d0272b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -30,6 +30,7 @@ + diff --git a/Ryujinx.sln b/Ryujinx.sln index deddb97a0..0ea302f66 100644 --- a/Ryujinx.sln +++ b/Ryujinx.sln @@ -59,6 +59,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ryujinx.Input", "src\Ryujin EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ryujinx.Input.SDL3", "src\Ryujinx.Input.SDL3\Ryujinx.Input.SDL3.csproj", "{D728444C-3D1F-4A0E-B4C9-5C9375D47EA3}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ryujinx.Input.Midi", "src\Ryujinx.Input.Midi\Ryujinx.Input.Midi.csproj", "{2E49F4FB-4ABA-49A5-9292-8C969F22F44E}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ryujinx.Graphics.Nvdec.FFmpeg", "src\Ryujinx.Graphics.Nvdec.FFmpeg\Ryujinx.Graphics.Nvdec.FFmpeg.csproj", "{BEE1C184-C9A4-410B-8DFC-FB74D5C93AEB}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ryujinx", "src\Ryujinx\Ryujinx.csproj", "{7C1B2721-13DA-4B62-B046-C626605ECCE6}" @@ -392,6 +394,18 @@ Global {C16F112F-38C3-40BC-9F5F-4791112063D6}.Release|x64.Build.0 = Release|Any CPU {C16F112F-38C3-40BC-9F5F-4791112063D6}.Release|x86.ActiveCfg = Release|Any CPU {C16F112F-38C3-40BC-9F5F-4791112063D6}.Release|x86.Build.0 = Release|Any CPU + {2E49F4FB-4ABA-49A5-9292-8C969F22F44E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2E49F4FB-4ABA-49A5-9292-8C969F22F44E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2E49F4FB-4ABA-49A5-9292-8C969F22F44E}.Debug|x64.ActiveCfg = Debug|Any CPU + {2E49F4FB-4ABA-49A5-9292-8C969F22F44E}.Debug|x64.Build.0 = Debug|Any CPU + {2E49F4FB-4ABA-49A5-9292-8C969F22F44E}.Debug|x86.ActiveCfg = Debug|Any CPU + {2E49F4FB-4ABA-49A5-9292-8C969F22F44E}.Debug|x86.Build.0 = Debug|Any CPU + {2E49F4FB-4ABA-49A5-9292-8C969F22F44E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2E49F4FB-4ABA-49A5-9292-8C969F22F44E}.Release|Any CPU.Build.0 = Release|Any CPU + {2E49F4FB-4ABA-49A5-9292-8C969F22F44E}.Release|x64.ActiveCfg = Release|Any CPU + {2E49F4FB-4ABA-49A5-9292-8C969F22F44E}.Release|x64.Build.0 = Release|Any CPU + {2E49F4FB-4ABA-49A5-9292-8C969F22F44E}.Release|x86.ActiveCfg = Release|Any CPU + {2E49F4FB-4ABA-49A5-9292-8C969F22F44E}.Release|x86.Build.0 = Release|Any CPU {BEE1C184-C9A4-410B-8DFC-FB74D5C93AEB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {BEE1C184-C9A4-410B-8DFC-FB74D5C93AEB}.Debug|Any CPU.Build.0 = Debug|Any CPU {BEE1C184-C9A4-410B-8DFC-FB74D5C93AEB}.Debug|x64.ActiveCfg = Debug|Any CPU diff --git a/src/Ryujinx.Common/Configuration/Hid/InputBackendType.cs b/src/Ryujinx.Common/Configuration/Hid/InputBackendType.cs index c3336dc64..b1b9cd2f2 100644 --- a/src/Ryujinx.Common/Configuration/Hid/InputBackendType.cs +++ b/src/Ryujinx.Common/Configuration/Hid/InputBackendType.cs @@ -7,6 +7,7 @@ namespace Ryujinx.Common.Configuration.Hid { Invalid, WindowKeyboard, + Midi, GamepadSDL2, //backcompat GamepadSDL3, } diff --git a/src/Ryujinx.Common/Configuration/Hid/InputConfigJsonSerializerContext.cs b/src/Ryujinx.Common/Configuration/Hid/InputConfigJsonSerializerContext.cs index bd8be2491..74abcf71f 100644 --- a/src/Ryujinx.Common/Configuration/Hid/InputConfigJsonSerializerContext.cs +++ b/src/Ryujinx.Common/Configuration/Hid/InputConfigJsonSerializerContext.cs @@ -1,5 +1,6 @@ using Ryujinx.Common.Configuration.Hid.Controller; using Ryujinx.Common.Configuration.Hid.Keyboard; +using Ryujinx.Common.Configuration.Hid.Midi; using System.Text.Json.Serialization; namespace Ryujinx.Common.Configuration.Hid @@ -7,6 +8,7 @@ namespace Ryujinx.Common.Configuration.Hid [JsonSourceGenerationOptions(WriteIndented = true)] [JsonSerializable(typeof(InputConfig))] [JsonSerializable(typeof(StandardKeyboardInputConfig))] + [JsonSerializable(typeof(StandardMidiInputConfig))] [JsonSerializable(typeof(StandardControllerInputConfig))] public partial class InputConfigJsonSerializerContext : JsonSerializerContext { diff --git a/src/Ryujinx.Common/Configuration/Hid/JsonInputConfigConverter.cs b/src/Ryujinx.Common/Configuration/Hid/JsonInputConfigConverter.cs index eadaab492..a2c3c5a61 100644 --- a/src/Ryujinx.Common/Configuration/Hid/JsonInputConfigConverter.cs +++ b/src/Ryujinx.Common/Configuration/Hid/JsonInputConfigConverter.cs @@ -1,5 +1,6 @@ using Ryujinx.Common.Configuration.Hid.Controller; using Ryujinx.Common.Configuration.Hid.Keyboard; +using Ryujinx.Common.Configuration.Hid.Midi; using Ryujinx.Common.Utilities; using System; using System.Text.Json; @@ -58,6 +59,7 @@ namespace Ryujinx.Common.Configuration.Hid return backendType switch { InputBackendType.WindowKeyboard => JsonSerializer.Deserialize(ref reader, _serializerContext.StandardKeyboardInputConfig), + InputBackendType.Midi => JsonSerializer.Deserialize(ref reader, _serializerContext.StandardMidiInputConfig), InputBackendType.GamepadSDL2 or InputBackendType.GamepadSDL3 => JsonSerializer.Deserialize(ref reader, _serializerContext.StandardControllerInputConfig), _ => throw new InvalidOperationException($"Unknown backend type {backendType}"), }; @@ -70,6 +72,9 @@ namespace Ryujinx.Common.Configuration.Hid case InputBackendType.WindowKeyboard: JsonSerializer.Serialize(writer, value as StandardKeyboardInputConfig, _serializerContext.StandardKeyboardInputConfig); break; + case InputBackendType.Midi: + JsonSerializer.Serialize(writer, value as StandardMidiInputConfig, _serializerContext.StandardMidiInputConfig); + break; case InputBackendType.GamepadSDL2 or InputBackendType.GamepadSDL3: JsonSerializer.Serialize(writer, value as StandardControllerInputConfig, _serializerContext.StandardControllerInputConfig); break; diff --git a/src/Ryujinx.Common/Configuration/Hid/Midi/MidiBinding.cs b/src/Ryujinx.Common/Configuration/Hid/Midi/MidiBinding.cs new file mode 100644 index 000000000..8219062b0 --- /dev/null +++ b/src/Ryujinx.Common/Configuration/Hid/Midi/MidiBinding.cs @@ -0,0 +1,42 @@ +using System; + +namespace Ryujinx.Common.Configuration.Hid.Midi +{ + public struct MidiBinding : IEquatable + { + public MidiBindingKind Kind { get; set; } + public byte Number { get; set; } + public byte Channel { get; set; } + public byte Threshold { get; set; } + + public readonly bool IsUnbound => Kind == MidiBindingKind.Unbound; + + public readonly bool Equals(MidiBinding other) + { + return Kind == other.Kind && + Number == other.Number && + Channel == other.Channel && + Threshold == other.Threshold; + } + + public override readonly bool Equals(object obj) + { + return obj is MidiBinding other && Equals(other); + } + + public override readonly int GetHashCode() + { + return HashCode.Combine((byte)Kind, Number, Channel, Threshold); + } + + public static bool operator ==(MidiBinding left, MidiBinding right) + { + return left.Equals(right); + } + + public static bool operator !=(MidiBinding left, MidiBinding right) + { + return !left.Equals(right); + } + } +} diff --git a/src/Ryujinx.Common/Configuration/Hid/Midi/MidiBindingKind.cs b/src/Ryujinx.Common/Configuration/Hid/Midi/MidiBindingKind.cs new file mode 100644 index 000000000..68f26478a --- /dev/null +++ b/src/Ryujinx.Common/Configuration/Hid/Midi/MidiBindingKind.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Ryujinx.Common.Configuration.Hid.Midi +{ + [JsonConverter(typeof(JsonStringEnumConverter))] + public enum MidiBindingKind : byte + { + Unbound, + Note, + ControlChange, + } +} diff --git a/src/Ryujinx.Common/Configuration/Hid/Midi/StandardMidiInputConfig.cs b/src/Ryujinx.Common/Configuration/Hid/Midi/StandardMidiInputConfig.cs new file mode 100644 index 000000000..0b3bb4558 --- /dev/null +++ b/src/Ryujinx.Common/Configuration/Hid/Midi/StandardMidiInputConfig.cs @@ -0,0 +1,6 @@ +using Ryujinx.Common.Configuration.Hid.Keyboard; + +namespace Ryujinx.Common.Configuration.Hid.Midi +{ + public class StandardMidiInputConfig : GenericKeyboardInputConfig { } +} diff --git a/src/Ryujinx.Input.Midi/Assigner/MidiBindingAssigner.cs b/src/Ryujinx.Input.Midi/Assigner/MidiBindingAssigner.cs new file mode 100644 index 000000000..62acb05f1 --- /dev/null +++ b/src/Ryujinx.Input.Midi/Assigner/MidiBindingAssigner.cs @@ -0,0 +1,58 @@ +using Ryujinx.Common.Configuration.Hid.Midi; + +namespace Ryujinx.Input.Midi.Assigner +{ + public sealed class MidiBindingAssigner + { + private readonly IMidiGamepad _gamepad; + private MidiCapturedInput? _capturedInput; + + public MidiBindingAssigner(IMidiGamepad gamepad) + { + _gamepad = gamepad; + } + + public void Initialize() + { + _gamepad?.ResetCapturedInputs(); + _capturedInput = null; + } + + public void ReadInput() + { + if (_gamepad != null && _gamepad.TryDequeueCapturedInput(out MidiCapturedInput input)) + { + _capturedInput = input; + } + } + + public bool IsAnyButtonPressed() + { + return _capturedInput.HasValue; + } + + public bool ShouldCancel() + { + return _gamepad == null || !_gamepad.IsConnected; + } + + public MidiBinding? GetPressedBinding(bool captureAnyChannel, byte threshold) + { + if (!_capturedInput.HasValue) + { + return null; + } + + MidiCapturedInput input = _capturedInput.Value; + _capturedInput = null; + + return new MidiBinding + { + Kind = input.Kind, + Number = input.Number, + Channel = captureAnyChannel ? (byte)0 : input.Channel, + Threshold = threshold, + }; + } + } +} diff --git a/src/Ryujinx.Input.Midi/IMidiGamepad.cs b/src/Ryujinx.Input.Midi/IMidiGamepad.cs new file mode 100644 index 000000000..360dd0414 --- /dev/null +++ b/src/Ryujinx.Input.Midi/IMidiGamepad.cs @@ -0,0 +1,8 @@ +namespace Ryujinx.Input.Midi +{ + public interface IMidiGamepad : IGamepad + { + bool TryDequeueCapturedInput(out MidiCapturedInput input); + void ResetCapturedInputs(); + } +} diff --git a/src/Ryujinx.Input.Midi/MidiCapturedInput.cs b/src/Ryujinx.Input.Midi/MidiCapturedInput.cs new file mode 100644 index 000000000..ac13e332c --- /dev/null +++ b/src/Ryujinx.Input.Midi/MidiCapturedInput.cs @@ -0,0 +1,20 @@ +using Ryujinx.Common.Configuration.Hid.Midi; + +namespace Ryujinx.Input.Midi +{ + public readonly struct MidiCapturedInput + { + public MidiBindingKind Kind { get; } + public byte Number { get; } + public byte Channel { get; } + public byte Value { get; } + + public MidiCapturedInput(MidiBindingKind kind, byte number, byte channel, byte value) + { + Kind = kind; + Number = number; + Channel = channel; + Value = value; + } + } +} diff --git a/src/Ryujinx.Input.Midi/MidiDeviceConnection.cs b/src/Ryujinx.Input.Midi/MidiDeviceConnection.cs new file mode 100644 index 000000000..47e57d335 --- /dev/null +++ b/src/Ryujinx.Input.Midi/MidiDeviceConnection.cs @@ -0,0 +1,58 @@ +using Melanchall.DryWetMidi.Core; +using Melanchall.DryWetMidi.Multimedia; +using System; + +namespace Ryujinx.Input.Midi +{ + internal sealed class MidiDeviceConnection : IDisposable + { + private readonly InputDevice _device; + + public MidiDeviceState State { get; } = new(); + + public bool IsConnected { get; private set; } + + public MidiDeviceConnection(InputDevice device) + { + _device = device; + _device.EventReceived += HandleEventReceived; + _device.StartEventsListening(); + IsConnected = true; + } + + private void HandleEventReceived(object sender, MidiEventReceivedEventArgs e) + { + switch (e.Event) + { + case NoteOnEvent noteOn: + { + byte channel = (byte)noteOn.Channel; + byte noteNumber = (byte)noteOn.NoteNumber; + byte velocity = (byte)noteOn.Velocity; + + State.SetNoteValue(channel, noteNumber, velocity); + break; + } + case NoteOffEvent noteOff: + State.SetNoteValue((byte)noteOff.Channel, (byte)noteOff.NoteNumber, 0); + break; + case ControlChangeEvent controlChange: + State.SetControlValue((byte)controlChange.Channel, (byte)controlChange.ControlNumber, (byte)controlChange.ControlValue); + break; + } + } + + public void Dispose() + { + if (!IsConnected) + { + return; + } + + IsConnected = false; + _device.EventReceived -= HandleEventReceived; + _device.StopEventsListening(); + _device.Dispose(); + } + } +} diff --git a/src/Ryujinx.Input.Midi/MidiDeviceState.cs b/src/Ryujinx.Input.Midi/MidiDeviceState.cs new file mode 100644 index 000000000..c4bfa38be --- /dev/null +++ b/src/Ryujinx.Input.Midi/MidiDeviceState.cs @@ -0,0 +1,94 @@ +using Ryujinx.Common.Configuration.Hid.Midi; +using System.Collections.Generic; +using System.Threading; + +namespace Ryujinx.Input.Midi +{ + internal class MidiDeviceState + { + private readonly byte[,] _noteVelocities = new byte[16, 128]; + private readonly byte[,] _controlValues = new byte[16, 128]; + private readonly Queue _capturedInputs = []; + private readonly Lock _lock = new(); + + public void SetNoteValue(byte channel, byte note, byte velocity) + { + lock (_lock) + { + _noteVelocities[channel, note] = velocity; + + if (velocity > 0) + { + _capturedInputs.Enqueue(new MidiCapturedInput(MidiBindingKind.Note, note, (byte)(channel + 1), velocity)); + } + } + } + + public void SetControlValue(byte channel, byte control, byte value) + { + lock (_lock) + { + _controlValues[channel, control] = value; + _capturedInputs.Enqueue(new MidiCapturedInput(MidiBindingKind.ControlChange, control, (byte)(channel + 1), value)); + } + } + + public bool IsBindingActive(MidiBinding binding) + { + if (binding.IsUnbound) + { + return false; + } + + lock (_lock) + { + int threshold = binding.Threshold == 0 ? 1 : binding.Threshold; + + for (int channelIndex = 0; channelIndex < 16; channelIndex++) + { + if (binding.Channel != 0 && binding.Channel != channelIndex + 1) + { + continue; + } + + int currentValue = binding.Kind switch + { + MidiBindingKind.Note => _noteVelocities[channelIndex, binding.Number], + MidiBindingKind.ControlChange => _controlValues[channelIndex, binding.Number], + _ => 0, + }; + + if (currentValue >= threshold) + { + return true; + } + } + + return false; + } + } + + public bool TryDequeueCapturedInput(out MidiCapturedInput input) + { + lock (_lock) + { + if (_capturedInputs.Count > 0) + { + input = _capturedInputs.Dequeue(); + return true; + } + } + + input = default; + return false; + } + + public void ResetCapturedInputs() + { + lock (_lock) + { + _capturedInputs.Clear(); + } + } + } +} diff --git a/src/Ryujinx.Input.Midi/MidiGamepad.cs b/src/Ryujinx.Input.Midi/MidiGamepad.cs new file mode 100644 index 000000000..ed8f64518 --- /dev/null +++ b/src/Ryujinx.Input.Midi/MidiGamepad.cs @@ -0,0 +1,185 @@ +using Ryujinx.Common.Configuration.Hid; +using Ryujinx.Common.Configuration.Hid.Midi; +using System; +using System.Collections.Generic; +using System.Numerics; +using System.Threading; + +namespace Ryujinx.Input.Midi +{ + public sealed class MidiGamepad : IMidiGamepad + { + private readonly record struct ButtonMappingEntry(GamepadButtonInputId To, MidiBinding From) + { + public bool IsValid => To is not GamepadButtonInputId.Unbound && !From.IsUnbound; + } + + private readonly MidiDeviceConnection _connection; + private readonly List _buttonsUserMapping = []; + private readonly Lock _userMappingLock = new(); + + private StandardMidiInputConfig _configuration; + + public GamepadFeaturesFlag Features => GamepadFeaturesFlag.None; + public string Id { get; } + public string Name { get; } + public bool IsConnected => _connection.IsConnected; + + internal MidiGamepad(string id, string name, MidiDeviceConnection connection) + { + Id = id; + Name = name; + _connection = connection; + } + + public bool IsPressed(GamepadButtonInputId inputId) + { + return false; + } + + public (float, float) GetStick(StickInputId inputId) + { + return (0, 0); + } + + public Vector3 GetMotionData(MotionInputId inputId) + { + return Vector3.Zero; + } + + public void SetTriggerThreshold(float triggerThreshold) + { + } + + public void SetConfiguration(InputConfig configuration) + { + lock (_userMappingLock) + { + _configuration = (StandardMidiInputConfig)configuration; + _buttonsUserMapping.Clear(); + + _buttonsUserMapping.Add(new ButtonMappingEntry(GamepadButtonInputId.LeftStick, _configuration.LeftJoyconStick.StickButton)); + _buttonsUserMapping.Add(new ButtonMappingEntry(GamepadButtonInputId.DpadUp, _configuration.LeftJoycon.DpadUp)); + _buttonsUserMapping.Add(new ButtonMappingEntry(GamepadButtonInputId.DpadDown, _configuration.LeftJoycon.DpadDown)); + _buttonsUserMapping.Add(new ButtonMappingEntry(GamepadButtonInputId.DpadLeft, _configuration.LeftJoycon.DpadLeft)); + _buttonsUserMapping.Add(new ButtonMappingEntry(GamepadButtonInputId.DpadRight, _configuration.LeftJoycon.DpadRight)); + _buttonsUserMapping.Add(new ButtonMappingEntry(GamepadButtonInputId.Minus, _configuration.LeftJoycon.ButtonMinus)); + _buttonsUserMapping.Add(new ButtonMappingEntry(GamepadButtonInputId.LeftShoulder, _configuration.LeftJoycon.ButtonL)); + _buttonsUserMapping.Add(new ButtonMappingEntry(GamepadButtonInputId.LeftTrigger, _configuration.LeftJoycon.ButtonZl)); + _buttonsUserMapping.Add(new ButtonMappingEntry(GamepadButtonInputId.SingleRightTrigger0, _configuration.LeftJoycon.ButtonSr)); + _buttonsUserMapping.Add(new ButtonMappingEntry(GamepadButtonInputId.SingleLeftTrigger0, _configuration.LeftJoycon.ButtonSl)); + + _buttonsUserMapping.Add(new ButtonMappingEntry(GamepadButtonInputId.RightStick, _configuration.RightJoyconStick.StickButton)); + _buttonsUserMapping.Add(new ButtonMappingEntry(GamepadButtonInputId.A, _configuration.RightJoycon.ButtonA)); + _buttonsUserMapping.Add(new ButtonMappingEntry(GamepadButtonInputId.B, _configuration.RightJoycon.ButtonB)); + _buttonsUserMapping.Add(new ButtonMappingEntry(GamepadButtonInputId.X, _configuration.RightJoycon.ButtonX)); + _buttonsUserMapping.Add(new ButtonMappingEntry(GamepadButtonInputId.Y, _configuration.RightJoycon.ButtonY)); + _buttonsUserMapping.Add(new ButtonMappingEntry(GamepadButtonInputId.Plus, _configuration.RightJoycon.ButtonPlus)); + _buttonsUserMapping.Add(new ButtonMappingEntry(GamepadButtonInputId.RightShoulder, _configuration.RightJoycon.ButtonR)); + _buttonsUserMapping.Add(new ButtonMappingEntry(GamepadButtonInputId.RightTrigger, _configuration.RightJoycon.ButtonZr)); + _buttonsUserMapping.Add(new ButtonMappingEntry(GamepadButtonInputId.SingleRightTrigger1, _configuration.RightJoycon.ButtonSr)); + _buttonsUserMapping.Add(new ButtonMappingEntry(GamepadButtonInputId.SingleLeftTrigger1, _configuration.RightJoycon.ButtonSl)); + } + } + + public void SetLed(uint packedRgb) + { + } + + public void Rumble(float lowFrequency, float highFrequency, uint durationMs) + { + } + + public GamepadStateSnapshot GetMappedStateSnapshot() + { + GamepadStateSnapshot result = default; + + lock (_userMappingLock) + { + if (_configuration == null) + { + return result; + } + + foreach (ButtonMappingEntry entry in _buttonsUserMapping) + { + if (!entry.IsValid || result.IsPressed(entry.To)) + { + continue; + } + + result.SetPressed(entry.To, _connection.State.IsBindingActive(entry.From)); + } + + (short leftStickX, short leftStickY) = GetStickValues(_configuration.LeftJoyconStick); + (short rightStickX, short rightStickY) = GetStickValues(_configuration.RightJoyconStick); + + result.SetStick(StickInputId.Left, ConvertRawStickValue(leftStickX), ConvertRawStickValue(leftStickY)); + result.SetStick(StickInputId.Right, ConvertRawStickValue(rightStickX), ConvertRawStickValue(rightStickY)); + } + + return result; + } + + public GamepadStateSnapshot GetStateSnapshot() + { + return GetMappedStateSnapshot(); + } + + public bool TryDequeueCapturedInput(out MidiCapturedInput input) + { + return _connection.State.TryDequeueCapturedInput(out input); + } + + public void ResetCapturedInputs() + { + _connection.State.ResetCapturedInputs(); + } + + public void Dispose() + { + _connection.Dispose(); + } + + private static float ConvertRawStickValue(short value) + { + const float ConvertRate = 1.0f / (short.MaxValue + 0.5f); + + return value * ConvertRate; + } + + private (short, short) GetStickValues(Ryujinx.Common.Configuration.Hid.Keyboard.JoyconConfigKeyboardStick stickConfig) + { + short stickX = 0; + short stickY = 0; + + if (_connection.State.IsBindingActive(stickConfig.StickUp)) + { + stickY += 1; + } + + if (_connection.State.IsBindingActive(stickConfig.StickDown)) + { + stickY -= 1; + } + + if (_connection.State.IsBindingActive(stickConfig.StickRight)) + { + stickX += 1; + } + + if (_connection.State.IsBindingActive(stickConfig.StickLeft)) + { + stickX -= 1; + } + + Vector2 stick = new(stickX, stickY); + if (stick != Vector2.Zero) + { + stick = Vector2.Normalize(stick); + } + + return ((short)(stick.X * short.MaxValue), (short)(stick.Y * short.MaxValue)); + } + } +} diff --git a/src/Ryujinx.Input.Midi/MidiGamepadDriver.cs b/src/Ryujinx.Input.Midi/MidiGamepadDriver.cs new file mode 100644 index 000000000..e5ae144b4 --- /dev/null +++ b/src/Ryujinx.Input.Midi/MidiGamepadDriver.cs @@ -0,0 +1,101 @@ +using Melanchall.DryWetMidi.Multimedia; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Ryujinx.Input.Midi +{ + public sealed class MidiGamepadDriver : IGamepadDriver + { + public string DriverName => "MIDI"; + + public ReadOnlySpan GamepadsIds => EnumerateDeviceIds().ToArray(); + + public event Action OnGamepadConnected + { + add { } + remove { } + } + + public event Action OnGamepadDisconnected + { + add { } + remove { } + } + + public bool IsSupported => OperatingSystem.IsWindows() || OperatingSystem.IsMacOS(); + + public string GetDeviceName(string id) + { + return TryGetDeviceInfo(id, out _, out string name) ? name : null; + } + + public IGamepad GetGamepad(string id) + { + if (!TryGetDeviceInfo(id, out int index, out string name)) + { + return null; + } + + InputDevice device = InputDevice.GetByIndex(index); + + if (device == null) + { + return null; + } + + return new MidiGamepad(id, name, new MidiDeviceConnection(device)); + } + + public IEnumerable GetGamepads() + { + foreach (string id in EnumerateDeviceIds()) + { + IGamepad gamepad = GetGamepad(id); + + if (gamepad != null) + { + yield return gamepad; + } + } + } + + public void Dispose() + { + } + + private IEnumerable EnumerateDeviceIds() + { + if (!IsSupported) + { + yield break; + } + + for (int index = 0; index < InputDevice.GetDevicesCount(); index++) + { + yield return index.ToString(); + } + } + + private bool TryGetDeviceInfo(string id, out int index, out string name) + { + index = -1; + name = null; + + if (!IsSupported || !int.TryParse(id, out index)) + { + return false; + } + + if (index < 0 || index >= InputDevice.GetDevicesCount()) + { + return false; + } + + using InputDevice device = InputDevice.GetByIndex(index); + name = device?.Name; + + return device != null; + } + } +} diff --git a/src/Ryujinx.Input.Midi/Ryujinx.Input.Midi.csproj b/src/Ryujinx.Input.Midi/Ryujinx.Input.Midi.csproj new file mode 100644 index 000000000..997b6300e --- /dev/null +++ b/src/Ryujinx.Input.Midi/Ryujinx.Input.Midi.csproj @@ -0,0 +1,16 @@ + + + + true + $(DefaultItemExcludes);._* + + + + + + + + + + + diff --git a/src/Ryujinx.Input/HLE/InputManager.cs b/src/Ryujinx.Input/HLE/InputManager.cs index 2825542a0..a2cb0f9b4 100644 --- a/src/Ryujinx.Input/HLE/InputManager.cs +++ b/src/Ryujinx.Input/HLE/InputManager.cs @@ -1,14 +1,32 @@ +using Ryujinx.Common.Configuration.Hid; using System; +using System.Collections.Generic; namespace Ryujinx.Input.HLE { - public class InputManager(IGamepadDriver keyboardDriver, IGamepadDriver gamepadDriver) + public class InputManager(IGamepadDriver keyboardDriver, IGamepadDriver gamepadDriver, IGamepadDriver midiDriver = null) : IDisposable { + private readonly Dictionary _drivers = new() + { + { InputBackendType.WindowKeyboard, keyboardDriver }, + { InputBackendType.Midi, midiDriver }, + { InputBackendType.GamepadSDL2, gamepadDriver }, + { InputBackendType.GamepadSDL3, gamepadDriver }, + }; + public IGamepadDriver KeyboardDriver { get; } = keyboardDriver; public IGamepadDriver GamepadDriver { get; } = gamepadDriver; + public IGamepadDriver MidiDriver { get; } = midiDriver; public IGamepadDriver MouseDriver { get; private set; } + public IGamepadDriver GetDriver(InputBackendType backend) + { + _drivers.TryGetValue(backend, out IGamepadDriver driver); + + return driver; + } + public void SetMouseDriver(IGamepadDriver mouseDriver) { MouseDriver?.Dispose(); @@ -18,7 +36,7 @@ namespace Ryujinx.Input.HLE public NpadManager CreateNpadManager() { - return new NpadManager(KeyboardDriver, GamepadDriver, MouseDriver); + return new NpadManager(this, MouseDriver); } public TouchScreenManager CreateTouchScreenManager() @@ -37,6 +55,7 @@ namespace Ryujinx.Input.HLE { KeyboardDriver?.Dispose(); GamepadDriver?.Dispose(); + MidiDriver?.Dispose(); MouseDriver?.Dispose(); } } diff --git a/src/Ryujinx.Input/HLE/NpadManager.cs b/src/Ryujinx.Input/HLE/NpadManager.cs index f2936aa72..65b97391d 100644 --- a/src/Ryujinx.Input/HLE/NpadManager.cs +++ b/src/Ryujinx.Input/HLE/NpadManager.cs @@ -1,7 +1,5 @@ using Ryujinx.Common; using Ryujinx.Common.Configuration.Hid; -using Ryujinx.Common.Configuration.Hid.Controller; -using Ryujinx.Common.Configuration.Hid.Keyboard; using Ryujinx.HLE.HOS.Services.Hid; using System; using System.Buffers; @@ -30,6 +28,7 @@ namespace Ryujinx.Input.HLE private readonly NpadController[] _controllers; + private readonly InputManager _inputManager; private readonly IGamepadDriver _keyboardDriver; private readonly IGamepadDriver _gamepadDriver; private readonly IGamepadDriver _mouseDriver; @@ -43,13 +42,14 @@ namespace Ryujinx.Input.HLE private readonly List _hleInputStates = []; private readonly List _hleMotionStates = new(NpadDevices.MaxControllers); - public NpadManager(IGamepadDriver keyboardDriver, IGamepadDriver gamepadDriver, IGamepadDriver mouseDriver) + public NpadManager(InputManager inputManager, IGamepadDriver mouseDriver) { _controllers = new NpadController[MaxControllers]; _cemuHookClient = new CemuHookClient(this); - _keyboardDriver = keyboardDriver; - _gamepadDriver = gamepadDriver; + _inputManager = inputManager; + _keyboardDriver = inputManager.KeyboardDriver; + _gamepadDriver = inputManager.GamepadDriver; _mouseDriver = mouseDriver; _inputConfig = []; @@ -102,16 +102,7 @@ namespace Ryujinx.Input.HLE [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool DriverConfigurationUpdate(ref NpadController controller, InputConfig config) { - IGamepadDriver targetDriver = _gamepadDriver; - - if (config is StandardControllerInputConfig) - { - targetDriver = _gamepadDriver; - } - else if (config is StandardKeyboardInputConfig) - { - targetDriver = _keyboardDriver; - } + IGamepadDriver targetDriver = _inputManager.GetDriver(config.Backend); Debug.Assert(targetDriver != null, "Unknown input configuration!"); diff --git a/src/Ryujinx.Tests/HLE/MidiInputConfigTests.cs b/src/Ryujinx.Tests/HLE/MidiInputConfigTests.cs new file mode 100644 index 000000000..27391e818 --- /dev/null +++ b/src/Ryujinx.Tests/HLE/MidiInputConfigTests.cs @@ -0,0 +1,112 @@ +using NUnit.Framework; +using Ryujinx.Common.Configuration.Hid; +using Ryujinx.Common.Configuration.Hid.Keyboard; +using Ryujinx.Common.Configuration.Hid.Midi; +using Ryujinx.Common.Utilities; +using Ryujinx.Input; +using Ryujinx.Input.HLE; +using System; +using System.Collections.Generic; + +namespace Ryujinx.Tests.HLE +{ + public class MidiInputConfigTests + { + private static readonly InputConfigJsonSerializerContext SerializerContext = new(JsonHelper.GetDefaultSerializerOptions()); + + [Test] + public void MidiInputConfigRoundTripsThroughInputConfigConverter() + { + StandardMidiInputConfig config = new() + { + Version = InputConfig.CurrentVersion, + Backend = InputBackendType.Midi, + Id = "0", + Name = "Test MIDI Device", + ControllerType = ControllerType.ProController, + PlayerIndex = PlayerIndex.Player1, + LeftJoycon = new LeftJoyconCommonConfig + { + DpadUp = new MidiBinding { Kind = MidiBindingKind.Note, Number = 60, Channel = 1, Threshold = 10 }, + DpadDown = new MidiBinding { Kind = MidiBindingKind.ControlChange, Number = 1, Channel = 0, Threshold = 64 }, + }, + LeftJoyconStick = new JoyconConfigKeyboardStick + { + StickUp = new MidiBinding { Kind = MidiBindingKind.Note, Number = 61, Channel = 0, Threshold = 1 }, + StickButton = new MidiBinding { Kind = MidiBindingKind.ControlChange, Number = 64, Channel = 2, Threshold = 127 }, + }, + RightJoycon = new RightJoyconCommonConfig + { + ButtonA = new MidiBinding { Kind = MidiBindingKind.Note, Number = 72, Channel = 3, Threshold = 20 }, + }, + RightJoyconStick = new JoyconConfigKeyboardStick + { + StickLeft = new MidiBinding { Kind = MidiBindingKind.ControlChange, Number = 10, Channel = 0, Threshold = 90 }, + }, + }; + + string json = JsonHelper.Serialize(config, SerializerContext.InputConfig); + InputConfig roundTripped = JsonHelper.Deserialize(json, SerializerContext.InputConfig); + + Assert.That(roundTripped, Is.TypeOf()); + + StandardMidiInputConfig midiConfig = (StandardMidiInputConfig)roundTripped; + + Assert.Multiple(() => + { + Assert.That(midiConfig.Backend, Is.EqualTo(InputBackendType.Midi)); + Assert.That(midiConfig.LeftJoycon.DpadUp, Is.EqualTo(config.LeftJoycon.DpadUp)); + Assert.That(midiConfig.LeftJoycon.DpadDown, Is.EqualTo(config.LeftJoycon.DpadDown)); + Assert.That(midiConfig.LeftJoyconStick.StickUp, Is.EqualTo(config.LeftJoyconStick.StickUp)); + Assert.That(midiConfig.LeftJoyconStick.StickButton, Is.EqualTo(config.LeftJoyconStick.StickButton)); + Assert.That(midiConfig.RightJoycon.ButtonA, Is.EqualTo(config.RightJoycon.ButtonA)); + Assert.That(midiConfig.RightJoyconStick.StickLeft, Is.EqualTo(config.RightJoyconStick.StickLeft)); + }); + } + + [Test] + public void InputManagerReturnsMidiDriverForMidiBackend() + { + using StubGamepadDriver keyboard = new("Keyboard"); + using StubGamepadDriver gamepad = new("Gamepad"); + using StubGamepadDriver midi = new("MIDI"); + using InputManager inputManager = new(keyboard, gamepad, midi); + + Assert.That(inputManager.GetDriver(InputBackendType.WindowKeyboard), Is.SameAs(keyboard)); + Assert.That(inputManager.GetDriver(InputBackendType.GamepadSDL3), Is.SameAs(gamepad)); + Assert.That(inputManager.GetDriver(InputBackendType.Midi), Is.SameAs(midi)); + } + + private sealed class StubGamepadDriver(string driverName) : IGamepadDriver + { + public string DriverName { get; } = driverName; + public ReadOnlySpan GamepadsIds => []; + + public event Action OnGamepadConnected + { + add { } + remove { } + } + + public event Action OnGamepadDisconnected + { + add { } + remove { } + } + + public IGamepad GetGamepad(string id) + { + return null; + } + + public IEnumerable GetGamepads() + { + yield break; + } + + public void Dispose() + { + } + } + } +} diff --git a/src/Ryujinx.Tests/Ryujinx.Tests.csproj b/src/Ryujinx.Tests/Ryujinx.Tests.csproj index 2ac6410ef..e23942457 100644 --- a/src/Ryujinx.Tests/Ryujinx.Tests.csproj +++ b/src/Ryujinx.Tests/Ryujinx.Tests.csproj @@ -27,6 +27,7 @@ + diff --git a/src/Ryujinx/Headless/HeadlessRyujinx.Init.cs b/src/Ryujinx/Headless/HeadlessRyujinx.Init.cs index af61b7b63..02d0052ab 100644 --- a/src/Ryujinx/Headless/HeadlessRyujinx.Init.cs +++ b/src/Ryujinx/Headless/HeadlessRyujinx.Init.cs @@ -9,6 +9,7 @@ using Ryujinx.Common.Configuration.Hid; using Ryujinx.Common.Configuration.Hid.Controller; using Ryujinx.Common.Configuration.Hid.Controller.Motion; using Ryujinx.Common.Configuration.Hid.Keyboard; +using Ryujinx.Common.Configuration.Hid.Midi; using Ryujinx.Common.Logging; using Ryujinx.Common.Utilities; using Ryujinx.Cpu; @@ -74,18 +75,24 @@ namespace Ryujinx.Headless IGamepad gamepad = _inputManager.KeyboardDriver.GetGamepad(inputId); - bool isKeyboard = true; + InputBackendType inputBackend = InputBackendType.WindowKeyboard; if (gamepad == null) { gamepad = _inputManager.GamepadDriver.GetGamepad(inputId); - isKeyboard = false; + inputBackend = InputBackendType.GamepadSDL3; if (gamepad == null) { - Logger.Error?.Print(LogClass.Application, $"{index} gamepad not found (\"{inputId}\")"); + gamepad = _inputManager.MidiDriver?.GetGamepad(inputId); + inputBackend = InputBackendType.Midi; - return null; + if (gamepad == null) + { + Logger.Error?.Print(LogClass.Application, $"{index} gamepad not found (\"{inputId}\")"); + + return null; + } } } @@ -97,7 +104,7 @@ namespace Ryujinx.Headless if (inputProfileName == null || inputProfileName.Equals("default")) { - if (isKeyboard) + if (inputBackend == InputBackendType.WindowKeyboard) { config = new StandardKeyboardInputConfig { @@ -150,6 +157,58 @@ namespace Ryujinx.Headless }, }; } + else if (inputBackend == InputBackendType.Midi) + { + static MidiBinding UnboundBinding() => new() { Kind = MidiBindingKind.Unbound, Threshold = 1 }; + + config = new StandardMidiInputConfig + { + Version = InputConfig.CurrentVersion, + Backend = InputBackendType.Midi, + Id = null, + ControllerType = ControllerType.JoyconPair, + LeftJoycon = new LeftJoyconCommonConfig + { + DpadUp = UnboundBinding(), + DpadDown = UnboundBinding(), + DpadLeft = UnboundBinding(), + DpadRight = UnboundBinding(), + ButtonMinus = UnboundBinding(), + ButtonL = UnboundBinding(), + ButtonZl = UnboundBinding(), + ButtonSl = UnboundBinding(), + ButtonSr = UnboundBinding(), + }, + LeftJoyconStick = new JoyconConfigKeyboardStick + { + StickUp = UnboundBinding(), + StickDown = UnboundBinding(), + StickLeft = UnboundBinding(), + StickRight = UnboundBinding(), + StickButton = UnboundBinding(), + }, + RightJoycon = new RightJoyconCommonConfig + { + ButtonA = UnboundBinding(), + ButtonB = UnboundBinding(), + ButtonX = UnboundBinding(), + ButtonY = UnboundBinding(), + ButtonPlus = UnboundBinding(), + ButtonR = UnboundBinding(), + ButtonZr = UnboundBinding(), + ButtonSl = UnboundBinding(), + ButtonSr = UnboundBinding(), + }, + RightJoyconStick = new JoyconConfigKeyboardStick + { + StickUp = UnboundBinding(), + StickDown = UnboundBinding(), + StickLeft = UnboundBinding(), + StickRight = UnboundBinding(), + StickButton = UnboundBinding(), + }, + }; + } else { bool isNintendoStyle = gamepadName.Contains("Nintendo"); @@ -229,10 +288,14 @@ namespace Ryujinx.Headless { string profileBasePath; - if (isKeyboard) + if (inputBackend == InputBackendType.WindowKeyboard) { profileBasePath = Path.Combine(AppDataManager.ProfilesDirPath, "keyboard"); } + else if (inputBackend == InputBackendType.Midi) + { + profileBasePath = Path.Combine(AppDataManager.ProfilesDirPath, "midi"); + } else { profileBasePath = Path.Combine(AppDataManager.ProfilesDirPath, "controller"); @@ -262,7 +325,12 @@ namespace Ryujinx.Headless config.Id = inputId; config.PlayerIndex = index; - string inputTypeName = isKeyboard ? "Keyboard" : "Gamepad"; + string inputTypeName = inputBackend switch + { + InputBackendType.WindowKeyboard => "Keyboard", + InputBackendType.Midi => "MIDI", + _ => "Gamepad", + }; Logger.Info?.Print(LogClass.Application, $"{config.PlayerIndex} configured with {inputTypeName} \"{config.Id}\""); diff --git a/src/Ryujinx/Headless/HeadlessRyujinx.cs b/src/Ryujinx/Headless/HeadlessRyujinx.cs index bba505dbb..f521163a9 100644 --- a/src/Ryujinx/Headless/HeadlessRyujinx.cs +++ b/src/Ryujinx/Headless/HeadlessRyujinx.cs @@ -22,6 +22,7 @@ using Ryujinx.HLE.HOS; using Ryujinx.HLE.HOS.Services.Account.Acc; using Ryujinx.Input; using Ryujinx.Input.HLE; +using Ryujinx.Input.Midi; using Ryujinx.Input.SDL3; using Ryujinx.SDL3.Common; using System; @@ -181,7 +182,7 @@ namespace Ryujinx.Headless _accountManager = new AccountManager(_libHacHorizonManager.RyujinxClient, option.UserProfile); _userChannelPersistence = new UserChannelPersistence(); - _inputManager = new InputManager(new SDL3KeyboardDriver(), new SDL3GamepadDriver()); + _inputManager = new InputManager(new SDL3KeyboardDriver(), new SDL3GamepadDriver(), new MidiGamepadDriver()); GraphicsConfig.EnableShaderCache = !option.DisableShaderCache; @@ -216,6 +217,18 @@ namespace Ryujinx.Headless gamepad.Dispose(); } + if (_inputManager.MidiDriver != null) + { + foreach (string id in _inputManager.MidiDriver.GamepadsIds) + { + IGamepad gamepad = _inputManager.MidiDriver.GetGamepad(id); + + Logger.Info?.Print(LogClass.Application, $"- {id} (\"{gamepad.Name}\")"); + + gamepad.Dispose(); + } + } + return; } diff --git a/src/Ryujinx/Ryujinx.csproj b/src/Ryujinx/Ryujinx.csproj index 489b2c313..b2d795e92 100644 --- a/src/Ryujinx/Ryujinx.csproj +++ b/src/Ryujinx/Ryujinx.csproj @@ -76,6 +76,7 @@ + diff --git a/src/Ryujinx/UI/Helpers/Converters/KeyValueConverter.cs b/src/Ryujinx/UI/Helpers/Converters/KeyValueConverter.cs index d153adc74..cd1fbad60 100644 --- a/src/Ryujinx/UI/Helpers/Converters/KeyValueConverter.cs +++ b/src/Ryujinx/UI/Helpers/Converters/KeyValueConverter.cs @@ -2,6 +2,7 @@ using Avalonia.Data.Converters; using Ryujinx.Ava.Common.Locale; using Ryujinx.Common.Configuration.Hid; using Ryujinx.Common.Configuration.Hid.Controller; +using Ryujinx.Common.Configuration.Hid.Midi; using System; using System.Collections.Generic; using System.Globalization; @@ -173,6 +174,15 @@ namespace Ryujinx.Ava.UI.Helpers keyString = stickInputId.ToString(); } + break; + case MidiBinding midiBinding: + keyString = midiBinding.Kind switch + { + MidiBindingKind.Unbound => LocaleManager.Instance[LocaleKeys.KeyUnbound], + MidiBindingKind.Note => $"Note {midiBinding.Number} Ch {(midiBinding.Channel == 0 ? "Any" : midiBinding.Channel)} T{midiBinding.Threshold}", + MidiBindingKind.ControlChange => $"CC {midiBinding.Number} Ch {(midiBinding.Channel == 0 ? "Any" : midiBinding.Channel)} T{midiBinding.Threshold}", + _ => midiBinding.ToString(), + }; break; } diff --git a/src/Ryujinx/UI/Models/DeviceType.cs b/src/Ryujinx/UI/Models/DeviceType.cs index bb4fc3b30..d999a1794 100644 --- a/src/Ryujinx/UI/Models/DeviceType.cs +++ b/src/Ryujinx/UI/Models/DeviceType.cs @@ -4,6 +4,7 @@ namespace Ryujinx.Ava.UI.Models { None, Keyboard, + Midi, Controller, } } diff --git a/src/Ryujinx/UI/Models/Input/MidiInputConfig.cs b/src/Ryujinx/UI/Models/Input/MidiInputConfig.cs new file mode 100644 index 000000000..ea69131ba --- /dev/null +++ b/src/Ryujinx/UI/Models/Input/MidiInputConfig.cs @@ -0,0 +1,202 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using Ryujinx.Ava.UI.ViewModels; +using Ryujinx.Common.Configuration.Hid; +using Ryujinx.Common.Configuration.Hid.Midi; + +namespace Ryujinx.Ava.UI.Models.Input +{ + public partial class MidiInputConfig : BaseModel + { + public string Id { get; set; } + public string Name { get; set; } + public ControllerType ControllerType { get; set; } + public PlayerIndex PlayerIndex { get; set; } + + [ObservableProperty] + public partial MidiBinding LeftStickUp { get; set; } + + [ObservableProperty] + public partial MidiBinding LeftStickDown { get; set; } + + [ObservableProperty] + public partial MidiBinding LeftStickLeft { get; set; } + + [ObservableProperty] + public partial MidiBinding LeftStickRight { get; set; } + + [ObservableProperty] + public partial MidiBinding LeftStickButton { get; set; } + + [ObservableProperty] + public partial MidiBinding RightStickUp { get; set; } + + [ObservableProperty] + public partial MidiBinding RightStickDown { get; set; } + + [ObservableProperty] + public partial MidiBinding RightStickLeft { get; set; } + + [ObservableProperty] + public partial MidiBinding RightStickRight { get; set; } + + [ObservableProperty] + public partial MidiBinding RightStickButton { get; set; } + + [ObservableProperty] + public partial MidiBinding DpadUp { get; set; } + + [ObservableProperty] + public partial MidiBinding DpadDown { get; set; } + + [ObservableProperty] + public partial MidiBinding DpadLeft { get; set; } + + [ObservableProperty] + public partial MidiBinding DpadRight { get; set; } + + [ObservableProperty] + public partial MidiBinding ButtonMinus { get; set; } + + [ObservableProperty] + public partial MidiBinding ButtonPlus { get; set; } + + [ObservableProperty] + public partial MidiBinding ButtonA { get; set; } + + [ObservableProperty] + public partial MidiBinding ButtonB { get; set; } + + [ObservableProperty] + public partial MidiBinding ButtonX { get; set; } + + [ObservableProperty] + public partial MidiBinding ButtonY { get; set; } + + [ObservableProperty] + public partial MidiBinding ButtonL { get; set; } + + [ObservableProperty] + public partial MidiBinding ButtonR { get; set; } + + [ObservableProperty] + public partial MidiBinding ButtonZl { get; set; } + + [ObservableProperty] + public partial MidiBinding ButtonZr { get; set; } + + [ObservableProperty] + public partial MidiBinding LeftButtonSl { get; set; } + + [ObservableProperty] + public partial MidiBinding LeftButtonSr { get; set; } + + [ObservableProperty] + public partial MidiBinding RightButtonSl { get; set; } + + [ObservableProperty] + public partial MidiBinding RightButtonSr { get; set; } + + public MidiInputConfig(InputConfig config) + { + if (config == null) + { + return; + } + + Id = config.Id; + Name = config.Name; + ControllerType = config.ControllerType; + PlayerIndex = config.PlayerIndex; + + if (config is not StandardMidiInputConfig midiConfig) + { + return; + } + + LeftStickUp = midiConfig.LeftJoyconStick.StickUp; + LeftStickDown = midiConfig.LeftJoyconStick.StickDown; + LeftStickLeft = midiConfig.LeftJoyconStick.StickLeft; + LeftStickRight = midiConfig.LeftJoyconStick.StickRight; + LeftStickButton = midiConfig.LeftJoyconStick.StickButton; + + RightStickUp = midiConfig.RightJoyconStick.StickUp; + RightStickDown = midiConfig.RightJoyconStick.StickDown; + RightStickLeft = midiConfig.RightJoyconStick.StickLeft; + RightStickRight = midiConfig.RightJoyconStick.StickRight; + RightStickButton = midiConfig.RightJoyconStick.StickButton; + + DpadUp = midiConfig.LeftJoycon.DpadUp; + DpadDown = midiConfig.LeftJoycon.DpadDown; + DpadLeft = midiConfig.LeftJoycon.DpadLeft; + DpadRight = midiConfig.LeftJoycon.DpadRight; + ButtonL = midiConfig.LeftJoycon.ButtonL; + ButtonMinus = midiConfig.LeftJoycon.ButtonMinus; + LeftButtonSl = midiConfig.LeftJoycon.ButtonSl; + LeftButtonSr = midiConfig.LeftJoycon.ButtonSr; + ButtonZl = midiConfig.LeftJoycon.ButtonZl; + + ButtonA = midiConfig.RightJoycon.ButtonA; + ButtonB = midiConfig.RightJoycon.ButtonB; + ButtonX = midiConfig.RightJoycon.ButtonX; + ButtonY = midiConfig.RightJoycon.ButtonY; + ButtonR = midiConfig.RightJoycon.ButtonR; + ButtonPlus = midiConfig.RightJoycon.ButtonPlus; + RightButtonSl = midiConfig.RightJoycon.ButtonSl; + RightButtonSr = midiConfig.RightJoycon.ButtonSr; + ButtonZr = midiConfig.RightJoycon.ButtonZr; + } + + public InputConfig GetConfig() + { + return new StandardMidiInputConfig + { + Id = Id, + Name = Name, + Backend = InputBackendType.Midi, + PlayerIndex = PlayerIndex, + ControllerType = ControllerType, + LeftJoycon = new LeftJoyconCommonConfig + { + DpadUp = DpadUp, + DpadDown = DpadDown, + DpadLeft = DpadLeft, + DpadRight = DpadRight, + ButtonL = ButtonL, + ButtonMinus = ButtonMinus, + ButtonZl = ButtonZl, + ButtonSl = LeftButtonSl, + ButtonSr = LeftButtonSr, + }, + RightJoycon = new RightJoyconCommonConfig + { + ButtonA = ButtonA, + ButtonB = ButtonB, + ButtonX = ButtonX, + ButtonY = ButtonY, + ButtonPlus = ButtonPlus, + ButtonSl = RightButtonSl, + ButtonSr = RightButtonSr, + ButtonR = ButtonR, + ButtonZr = ButtonZr, + }, + LeftJoyconStick = new Ryujinx.Common.Configuration.Hid.Keyboard.JoyconConfigKeyboardStick + { + StickUp = LeftStickUp, + StickDown = LeftStickDown, + StickRight = LeftStickRight, + StickLeft = LeftStickLeft, + StickButton = LeftStickButton, + }, + RightJoyconStick = new Ryujinx.Common.Configuration.Hid.Keyboard.JoyconConfigKeyboardStick + { + StickUp = RightStickUp, + StickDown = RightStickDown, + StickLeft = RightStickLeft, + StickRight = RightStickRight, + StickButton = RightStickButton, + }, + Version = InputConfig.CurrentVersion, + }; + } + } +} diff --git a/src/Ryujinx/UI/Models/Input/StickVisualizer.cs b/src/Ryujinx/UI/Models/Input/StickVisualizer.cs index f88f4ea72..16991d699 100644 --- a/src/Ryujinx/UI/Models/Input/StickVisualizer.cs +++ b/src/Ryujinx/UI/Models/Input/StickVisualizer.cs @@ -61,6 +61,18 @@ namespace Ryujinx.Ava.UI.Models.Input } } + private MidiInputConfig _midiConfig; + public MidiInputConfig MidiConfig + { + get => _midiConfig; + set + { + _midiConfig = value; + + OnPropertyChanged(); + } + } + private (float, float) _uiStickLeft; public (float, float) UiStickLeft { @@ -131,6 +143,13 @@ namespace Ryujinx.Ava.UI.Models.Input return; } + else if (config is MidiInputViewModel midiConfig) + { + MidiConfig = midiConfig.Config; + Type = DeviceType.Midi; + + return; + } Type = DeviceType.None; } @@ -209,6 +228,17 @@ namespace Ryujinx.Ava.UI.Models.Input rightBuffer = controller.GetStick((StickInputId)GamepadConfig.RightJoystick); } + break; + case DeviceType.Midi: + IGamepad midi = Parent.SelectedGamepad; + + if (midi != null) + { + GamepadStateSnapshot snapshot = midi.GetMappedStateSnapshot(); + leftBuffer = snapshot.GetStick(StickInputId.Left); + rightBuffer = snapshot.GetStick(StickInputId.Right); + } + break; case DeviceType.None: @@ -252,6 +282,7 @@ namespace Ryujinx.Ava.UI.Models.Input } KeyboardConfig = null; + MidiConfig = null; GamepadConfig = null; Parent = null; diff --git a/src/Ryujinx/UI/ViewModels/Input/InputViewModel.cs b/src/Ryujinx/UI/ViewModels/Input/InputViewModel.cs index e5f085e0f..ff935f791 100644 --- a/src/Ryujinx/UI/ViewModels/Input/InputViewModel.cs +++ b/src/Ryujinx/UI/ViewModels/Input/InputViewModel.cs @@ -16,9 +16,11 @@ using Ryujinx.Common.Configuration.Hid; using Ryujinx.Common.Configuration.Hid.Controller; using Ryujinx.Common.Configuration.Hid.Controller.Motion; using Ryujinx.Common.Configuration.Hid.Keyboard; +using Ryujinx.Common.Configuration.Hid.Midi; using Ryujinx.Common.Logging; using Ryujinx.Common.Utilities; using Ryujinx.Input; +using Ryujinx.Input.Midi; using System; using System.Collections.Generic; using System.Collections.ObjectModel; @@ -40,6 +42,7 @@ namespace Ryujinx.Ava.UI.ViewModels.Input private const string JoyConLeftResource = "Ryujinx/Assets/Icons/Controller_JoyConLeft.svg"; private const string JoyConRightResource = "Ryujinx/Assets/Icons/Controller_JoyConRight.svg"; private const string KeyboardString = "keyboard"; + private const string MidiString = "midi"; private const string ControllerString = "controller"; private readonly MainWindow _mainWindow; @@ -95,13 +98,15 @@ namespace Ryujinx.Ava.UI.ViewModels.Input // XAML Flags public bool ShowSettings => _device > 0; - public bool IsController => _device > 1; - public bool IsKeyboard => !IsController; + public DeviceType CurrentDeviceType => _device >= 0 && _device < Devices.Count ? Devices[_device].Type : DeviceType.None; + public bool IsController => CurrentDeviceType == DeviceType.Controller; + public bool IsKeyboard => CurrentDeviceType == DeviceType.Keyboard; + public bool IsMidi => CurrentDeviceType == DeviceType.Midi; public bool IsRight { get; set; } public bool IsLeft { get; set; } public string RevertDeviceId { get; set; } - public bool HasLed => (SelectedGamepad.Features & GamepadFeaturesFlag.Led) != 0; - public bool CanClearLed => SelectedGamepad.Name.ContainsIgnoreCase("DualSense"); + public bool HasLed => SelectedGamepad != null && (SelectedGamepad.Features & GamepadFeaturesFlag.Led) != 0; + public bool CanClearLed => SelectedGamepad?.Name.ContainsIgnoreCase("DualSense") == true; public event Action NotifyChangesEvent; @@ -349,6 +354,11 @@ namespace Ryujinx.Ava.UI.ViewModels.Input ConfigViewModel = new KeyboardInputViewModel(this, new KeyboardInputConfig(keyboardInputConfig), VisualStick); } + if (Config is StandardMidiInputConfig midiInputConfig) + { + ConfigViewModel = new MidiInputViewModel(this, new MidiInputConfig(midiInputConfig), VisualStick); + } + if (Config is StandardControllerInputConfig controllerInputConfig) { ConfigViewModel = new ControllerInputViewModel(this, new GamepadInputConfig(controllerInputConfig), VisualStick); @@ -407,6 +417,10 @@ namespace Ryujinx.Ava.UI.ViewModels.Input { type = DeviceType.Keyboard; } + else if (Config is StandardMidiInputConfig) + { + type = DeviceType.Midi; + } if (Config is StandardControllerInputConfig) { @@ -452,6 +466,10 @@ namespace Ryujinx.Ava.UI.ViewModels.Input SelectedGamepad = _mainWindow.InputManager.KeyboardDriver.GetGamepad(id); } } + else if (type == DeviceType.Midi) + { + SelectedGamepad = _mainWindow.InputManager.MidiDriver?.GetGamepad(id); + } else { SelectedGamepad = _mainWindow.InputManager.GamepadDriver.GetGamepad(id); @@ -609,6 +627,19 @@ namespace Ryujinx.Ava.UI.ViewModels.Input } } + if (_mainWindow.InputManager.MidiDriver is MidiGamepadDriver midiDriver) + { + foreach (string id in midiDriver.GamepadsIds) + { + string name = midiDriver.GetDeviceName(id); + + if (!string.IsNullOrWhiteSpace(name)) + { + Devices.Add((DeviceType.Midi, id, GetShortGamepadName(name))); + } + } + } + DeviceList.AddRange(Devices.Select(x => x.Name)); Device = Math.Min(Device, DeviceList.Count); } @@ -623,6 +654,10 @@ namespace Ryujinx.Ava.UI.ViewModels.Input { path = Path.Combine(path, KeyboardString); } + else if (type == DeviceType.Midi) + { + path = Path.Combine(path, MidiString); + } else if (type == DeviceType.Controller) { path = Path.Combine(path, ControllerString); @@ -720,6 +755,62 @@ namespace Ryujinx.Ava.UI.ViewModels.Input }, }; } + else if (activeDevice.Type == DeviceType.Midi) + { + string id = activeDevice.Id; + string name = activeDevice.Name; + + static MidiBinding UnboundBinding() => new() { Kind = MidiBindingKind.Unbound, Threshold = 1 }; + + config = new StandardMidiInputConfig + { + Version = InputConfig.CurrentVersion, + Backend = InputBackendType.Midi, + Id = id, + Name = name, + ControllerType = ControllerType.ProController, + LeftJoycon = new LeftJoyconCommonConfig + { + DpadUp = UnboundBinding(), + DpadDown = UnboundBinding(), + DpadLeft = UnboundBinding(), + DpadRight = UnboundBinding(), + ButtonMinus = UnboundBinding(), + ButtonL = UnboundBinding(), + ButtonZl = UnboundBinding(), + ButtonSl = UnboundBinding(), + ButtonSr = UnboundBinding(), + }, + LeftJoyconStick = new JoyconConfigKeyboardStick + { + StickUp = UnboundBinding(), + StickDown = UnboundBinding(), + StickLeft = UnboundBinding(), + StickRight = UnboundBinding(), + StickButton = UnboundBinding(), + }, + RightJoycon = new RightJoyconCommonConfig + { + ButtonA = UnboundBinding(), + ButtonB = UnboundBinding(), + ButtonX = UnboundBinding(), + ButtonY = UnboundBinding(), + ButtonPlus = UnboundBinding(), + ButtonR = UnboundBinding(), + ButtonZr = UnboundBinding(), + ButtonSl = UnboundBinding(), + ButtonSr = UnboundBinding(), + }, + RightJoyconStick = new JoyconConfigKeyboardStick + { + StickUp = UnboundBinding(), + StickDown = UnboundBinding(), + StickLeft = UnboundBinding(), + StickRight = UnboundBinding(), + StickButton = UnboundBinding(), + }, + }; + } else if (activeDevice.Type == DeviceType.Controller) { bool isNintendoStyle = Devices.ToList().FirstOrDefault(x => x.Id == activeDevice.Id).Name.Contains("Nintendo"); @@ -905,6 +996,10 @@ namespace Ryujinx.Ava.UI.ViewModels.Input { config = (ConfigViewModel as KeyboardInputViewModel).Config.GetConfig(); } + else if (IsMidi) + { + config = (ConfigViewModel as MidiInputViewModel).Config.GetConfig(); + } else if (IsController) { config = (ConfigViewModel as ControllerInputViewModel).Config.GetConfig(); @@ -1008,15 +1103,18 @@ namespace Ryujinx.Ava.UI.ViewModels.Input KeyboardInputConfig inputConfig = (ConfigViewModel as KeyboardInputViewModel).Config; inputConfig.Id = device.Id; } + else if (device.Type == DeviceType.Midi) + { + MidiInputConfig inputConfig = (ConfigViewModel as MidiInputViewModel).Config; + inputConfig.Id = device.Id; + } else { GamepadInputConfig inputConfig = (ConfigViewModel as ControllerInputViewModel).Config; inputConfig.Id = device.Id.Split(" ")[0]; } - InputConfig config = !IsController - ? (ConfigViewModel as KeyboardInputViewModel).Config.GetConfig() - : (ConfigViewModel as ControllerInputViewModel).Config.GetConfig(); + InputConfig config = GetCurrentConfigFromViewModel(); config.ControllerType = Controllers[_controller].Type; config.PlayerIndex = _playerId; config.Name = device.Name; @@ -1055,6 +1153,7 @@ namespace Ryujinx.Ava.UI.ViewModels.Input OnPropertyChanged(nameof(IsController)); OnPropertyChanged(nameof(ShowSettings)); OnPropertyChanged(nameof(IsKeyboard)); + OnPropertyChanged(nameof(IsMidi)); OnPropertyChanged(nameof(IsRight)); OnPropertyChanged(nameof(IsLeft)); NotifyChangesEvent?.Invoke(); @@ -1075,5 +1174,16 @@ namespace Ryujinx.Ava.UI.ViewModels.Input AvaloniaKeyboardDriver.Dispose(); } + + private InputConfig GetCurrentConfigFromViewModel() + { + return ConfigViewModel switch + { + KeyboardInputViewModel keyboardViewModel => keyboardViewModel.Config.GetConfig(), + MidiInputViewModel midiViewModel => midiViewModel.Config.GetConfig(), + ControllerInputViewModel controllerViewModel => controllerViewModel.Config.GetConfig(), + _ => null, + }; + } } } diff --git a/src/Ryujinx/UI/ViewModels/Input/KeyboardInputViewModel.cs b/src/Ryujinx/UI/ViewModels/Input/KeyboardInputViewModel.cs index 178e2c955..f00e33e93 100644 --- a/src/Ryujinx/UI/ViewModels/Input/KeyboardInputViewModel.cs +++ b/src/Ryujinx/UI/ViewModels/Input/KeyboardInputViewModel.cs @@ -6,6 +6,20 @@ namespace Ryujinx.Ava.UI.ViewModels.Input { public partial class KeyboardInputViewModel : BaseModel { + public bool ShowMidiCaptureOptions => false; + + public bool CaptureAnyChannel + { + get; + set; + } = true; + + public int CaptureThreshold + { + get; + set; + } = 1; + public KeyboardInputConfig Config { get; diff --git a/src/Ryujinx/UI/ViewModels/Input/MidiInputViewModel.cs b/src/Ryujinx/UI/ViewModels/Input/MidiInputViewModel.cs new file mode 100644 index 000000000..2fc55922d --- /dev/null +++ b/src/Ryujinx/UI/ViewModels/Input/MidiInputViewModel.cs @@ -0,0 +1,77 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using Ryujinx.Ava.UI.Models.Input; + +namespace Ryujinx.Ava.UI.ViewModels.Input +{ + public partial class MidiInputViewModel : BaseModel + { + public bool ShowMidiCaptureOptions => true; + + public MidiInputConfig Config + { + get; + set + { + field = value; + OnPropertyChanged(); + } + } + + public StickVisualizer Visualizer + { + get; + set + { + field = value; + OnPropertyChanged(); + } + } + + public bool IsLeft + { + get; + set + { + field = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(HasSides)); + } + } + + public bool IsRight + { + get; + set + { + field = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(HasSides)); + } + } + + public bool HasSides => IsLeft ^ IsRight; + + [ObservableProperty] + public partial int CaptureThreshold { get; set; } = 1; + + [ObservableProperty] + public partial bool CaptureAnyChannel { get; set; } = true; + + public readonly InputViewModel ParentModel; + + public MidiInputViewModel(InputViewModel model, MidiInputConfig config, StickVisualizer visualizer) + { + ParentModel = model; + Visualizer = visualizer; + model.NotifyChangesEvent += OnParentModelChanged; + OnParentModelChanged(); + Config = config; + } + + public void OnParentModelChanged() + { + IsLeft = ParentModel.IsLeft; + IsRight = ParentModel.IsRight; + } + } +} diff --git a/src/Ryujinx/UI/Views/Input/InputView.axaml b/src/Ryujinx/UI/Views/Input/InputView.axaml index c4f61e78e..ece178770 100644 --- a/src/Ryujinx/UI/Views/Input/InputView.axaml +++ b/src/Ryujinx/UI/Views/Input/InputView.axaml @@ -230,6 +230,9 @@ + + + diff --git a/src/Ryujinx/UI/Views/Input/KeyboardInputView.axaml b/src/Ryujinx/UI/Views/Input/KeyboardInputView.axaml index 22fd369d9..ce9af6d79 100644 --- a/src/Ryujinx/UI/Views/Input/KeyboardInputView.axaml +++ b/src/Ryujinx/UI/Views/Input/KeyboardInputView.axaml @@ -2,6 +2,7 @@ xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:ext="clr-namespace:Ryujinx.Ava.Common.Markup" + xmlns:controls="clr-namespace:Ryujinx.Ava.UI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:viewModels="clr-namespace:Ryujinx.Ava.UI.ViewModels.Input" @@ -11,8 +12,7 @@ d:DesignHeight="800" d:DesignWidth="800" x:Class="Ryujinx.Ava.UI.Views.Input.KeyboardInputView" - x:DataType="viewModels:KeyboardInputViewModel" - x:CompileBindings="True" + x:CompileBindings="False" mc:Ignorable="d" Focusable="True"> @@ -29,6 +29,37 @@ HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Orientation="Vertical"> + + + + + + + + + + + public partial class KeyboardInputView : RyujinxControl { - private ButtonKeyAssigner _currentAssigner; + private ButtonKeyAssigner _keyboardAssigner; + private ToggleButton _midiToggleButton; + private CancellationTokenSource _midiCancellationTokenSource; + private bool _shouldUnbindMidi; public KeyboardInputView() { @@ -37,157 +48,148 @@ namespace Ryujinx.Ava.UI.Views.Input { base.OnPointerReleased(e); - if (_currentAssigner is { ToggledButton.IsPointerOver: false }) + ToggleButton currentButton = GetCurrentToggleButton(); + + if (currentButton is { IsPointerOver: false }) { - _currentAssigner.Cancel(); + CancelCurrentAssignment(); } } - private void Button_IsCheckedChanged(object sender, RoutedEventArgs e) + private async void Button_IsCheckedChanged(object sender, RoutedEventArgs e) { if (sender is not ToggleButton button) - return; - - if (button.IsChecked is true) { - if (_currentAssigner != null && button == _currentAssigner.ToggledButton) + return; + } + + if (button.IsChecked is not true) + { + if (GetCurrentToggleButton() == button) { - return; + CancelCurrentAssignment(); } - if (_currentAssigner == null) - { - _currentAssigner = new ButtonKeyAssigner(button); + return; + } - Focus(NavigationMethod.Pointer); + if (GetCurrentToggleButton() == button) + { + return; + } - PointerPressed += MouseClick; + if (GetCurrentToggleButton() != null) + { + CancelCurrentAssignment(); + button.IsChecked = false; + return; + } - IKeyboard keyboard = - (IKeyboard)ViewModel.ParentModel.AvaloniaKeyboardDriver.GetGamepad("0"); // Open Avalonia keyboard for cancel operations. - IButtonAssigner assigner = - new KeyboardKeyAssigner((IKeyboard)ViewModel.ParentModel.SelectedGamepad); + Focus(NavigationMethod.Pointer); + PointerPressed += MouseClick; - _currentAssigner.ButtonAssigned += (_, be) => - { - if (be.ButtonValue.HasValue) - { - Button buttonValue = be.ButtonValue.Value; - ViewModel.ParentModel.IsModified = true; - - switch (button.Name) - { - case "ButtonZl": - ViewModel.Config.ButtonZl = buttonValue.AsHidType(); - break; - case "ButtonL": - ViewModel.Config.ButtonL = buttonValue.AsHidType(); - break; - case "ButtonMinus": - ViewModel.Config.ButtonMinus = buttonValue.AsHidType(); - break; - case "LeftStickButton": - ViewModel.Config.LeftStickButton = buttonValue.AsHidType(); - break; - case "LeftStickUp": - ViewModel.Config.LeftStickUp = buttonValue.AsHidType(); - break; - case "LeftStickDown": - ViewModel.Config.LeftStickDown = buttonValue.AsHidType(); - break; - case "LeftStickRight": - ViewModel.Config.LeftStickRight = buttonValue.AsHidType(); - break; - case "LeftStickLeft": - ViewModel.Config.LeftStickLeft = buttonValue.AsHidType(); - break; - case "DpadUp": - ViewModel.Config.DpadUp = buttonValue.AsHidType(); - break; - case "DpadDown": - ViewModel.Config.DpadDown = buttonValue.AsHidType(); - break; - case "DpadLeft": - ViewModel.Config.DpadLeft = buttonValue.AsHidType(); - break; - case "DpadRight": - ViewModel.Config.DpadRight = buttonValue.AsHidType(); - break; - case "LeftButtonSr": - ViewModel.Config.LeftButtonSr = buttonValue.AsHidType(); - break; - case "LeftButtonSl": - ViewModel.Config.LeftButtonSl = buttonValue.AsHidType(); - break; - case "RightButtonSr": - ViewModel.Config.RightButtonSr = buttonValue.AsHidType(); - break; - case "RightButtonSl": - ViewModel.Config.RightButtonSl = buttonValue.AsHidType(); - break; - case "ButtonZr": - ViewModel.Config.ButtonZr = buttonValue.AsHidType(); - break; - case "ButtonR": - ViewModel.Config.ButtonR = buttonValue.AsHidType(); - break; - case "ButtonPlus": - ViewModel.Config.ButtonPlus = buttonValue.AsHidType(); - break; - case "ButtonA": - ViewModel.Config.ButtonA = buttonValue.AsHidType(); - break; - case "ButtonB": - ViewModel.Config.ButtonB = buttonValue.AsHidType(); - break; - case "ButtonX": - ViewModel.Config.ButtonX = buttonValue.AsHidType(); - break; - case "ButtonY": - ViewModel.Config.ButtonY = buttonValue.AsHidType(); - break; - case "RightStickButton": - ViewModel.Config.RightStickButton = buttonValue.AsHidType(); - break; - case "RightStickUp": - ViewModel.Config.RightStickUp = buttonValue.AsHidType(); - break; - case "RightStickDown": - ViewModel.Config.RightStickDown = buttonValue.AsHidType(); - break; - case "RightStickRight": - ViewModel.Config.RightStickRight = buttonValue.AsHidType(); - break; - case "RightStickLeft": - ViewModel.Config.RightStickLeft = buttonValue.AsHidType(); - break; - } - } - }; - - _currentAssigner.GetInputAndAssign(assigner, keyboard); - } - else - { - if (_currentAssigner != null) - { - _currentAssigner.Cancel(); - _currentAssigner = null; - button.IsChecked = false; - } - } + if (DataContext is MidiInputViewModel midiViewModel) + { + await StartMidiAssignmentAsync(button, midiViewModel); } else { - _currentAssigner?.Cancel(); - _currentAssigner = null; + StartKeyboardAssignment(button); } } + private void StartKeyboardAssignment(ToggleButton button) + { + _keyboardAssigner = new ButtonKeyAssigner(button); + + IKeyboard keyboard = (IKeyboard)GetCancelKeyboard(); + IButtonAssigner assigner = new KeyboardKeyAssigner((IKeyboard)GetSelectedGamepad()); + + _keyboardAssigner.ButtonAssigned += (_, be) => + { + _keyboardAssigner = null; + + if (be.ButtonValue.HasValue) + { + Button buttonValue = be.ButtonValue.Value; + AssignKeyboardBinding(button.Name, buttonValue.AsHidType()); + } + }; + + _keyboardAssigner.GetInputAndAssign(assigner, keyboard); + } + + private async Task StartMidiAssignmentAsync(ToggleButton button, MidiInputViewModel viewModel) + { + if (GetSelectedGamepad() is not IMidiGamepad midiGamepad) + { + button.IsChecked = false; + PointerPressed -= MouseClick; + return; + } + + _midiToggleButton = button; + _shouldUnbindMidi = false; + _midiCancellationTokenSource = new CancellationTokenSource(); + + MidiBindingAssigner assigner = new(midiGamepad); + IKeyboard keyboard = (IKeyboard)GetCancelKeyboard(); + + assigner.Initialize(); + + MidiBinding? binding = null; + + try + { + binding = await Task.Run(async () => + { + while (!_midiCancellationTokenSource.IsCancellationRequested) + { + await Task.Delay(10, _midiCancellationTokenSource.Token); + + assigner.ReadInput(); + + if (assigner.IsAnyButtonPressed() || assigner.ShouldCancel() || (keyboard != null && keyboard.IsPressed(Ryujinx.Input.Key.Escape))) + { + break; + } + } + + if (_shouldUnbindMidi) + { + return CreateUnboundMidiBinding(); + } + + return assigner.GetPressedBinding(viewModel.CaptureAnyChannel, (byte)viewModel.CaptureThreshold); + }, _midiCancellationTokenSource.Token); + } + catch (OperationCanceledException) + { + } + + await Dispatcher.UIThread.InvokeAsync(() => + { + PointerPressed -= MouseClick; + + if (_midiToggleButton != null) + { + _midiToggleButton.IsChecked = false; + } + + _midiToggleButton = null; + _midiCancellationTokenSource?.Dispose(); + _midiCancellationTokenSource = null; + + if (binding.HasValue) + { + AssignMidiBinding(button.Name, binding.Value); + } + }); + } + private void MouseClick(object sender, PointerPressedEventArgs e) { bool shouldUnbind = e.GetCurrentPoint(this).Properties.IsMiddleButtonPressed; - bool shouldRemoveBinding = e.GetCurrentPoint(this).Properties.IsRightButtonPressed; if (shouldRemoveBinding) @@ -195,61 +197,177 @@ namespace Ryujinx.Ava.UI.Views.Input DeleteBind(); } - _currentAssigner?.Cancel(shouldUnbind); - + CancelCurrentAssignment(shouldUnbind); PointerPressed -= MouseClick; } private void DeleteBind() { + ToggleButton button = GetCurrentToggleButton(); - if (_currentAssigner != null) + if (button == null) { - Dictionary buttonActions = new() - { - { "ButtonZl", () => ViewModel.Config.ButtonZl = Key.Unbound }, - { "ButtonL", () => ViewModel.Config.ButtonL = Key.Unbound }, - { "ButtonMinus", () => ViewModel.Config.ButtonMinus = Key.Unbound }, - { "LeftStickButton", () => ViewModel.Config.LeftStickButton = Key.Unbound }, - { "LeftStickUp", () => ViewModel.Config.LeftStickUp = Key.Unbound }, - { "LeftStickDown", () => ViewModel.Config.LeftStickDown = Key.Unbound }, - { "LeftStickRight", () => ViewModel.Config.LeftStickRight = Key.Unbound }, - { "LeftStickLeft", () => ViewModel.Config.LeftStickLeft = Key.Unbound }, - { "DpadUp", () => ViewModel.Config.DpadUp = Key.Unbound }, - { "DpadDown", () => ViewModel.Config.DpadDown = Key.Unbound }, - { "DpadLeft", () => ViewModel.Config.DpadLeft = Key.Unbound }, - { "DpadRight", () => ViewModel.Config.DpadRight = Key.Unbound }, - { "LeftButtonSr", () => ViewModel.Config.LeftButtonSr = Key.Unbound }, - { "LeftButtonSl", () => ViewModel.Config.LeftButtonSl = Key.Unbound }, - { "RightButtonSr", () => ViewModel.Config.RightButtonSr = Key.Unbound }, - { "RightButtonSl", () => ViewModel.Config.RightButtonSl = Key.Unbound }, - { "ButtonZr", () => ViewModel.Config.ButtonZr = Key.Unbound }, - { "ButtonR", () => ViewModel.Config.ButtonR = Key.Unbound }, - { "ButtonPlus", () => ViewModel.Config.ButtonPlus = Key.Unbound }, - { "ButtonA", () => ViewModel.Config.ButtonA = Key.Unbound }, - { "ButtonB", () => ViewModel.Config.ButtonB = Key.Unbound }, - { "ButtonX", () => ViewModel.Config.ButtonX = Key.Unbound }, - { "ButtonY", () => ViewModel.Config.ButtonY = Key.Unbound }, - { "RightStickButton", () => ViewModel.Config.RightStickButton = Key.Unbound }, - { "RightStickUp", () => ViewModel.Config.RightStickUp = Key.Unbound }, - { "RightStickDown", () => ViewModel.Config.RightStickDown = Key.Unbound }, - { "RightStickRight", () => ViewModel.Config.RightStickRight = Key.Unbound }, - { "RightStickLeft", () => ViewModel.Config.RightStickLeft = Key.Unbound } - }; - - if (buttonActions.TryGetValue(_currentAssigner.ToggledButton.Name, out Action action)) - { - action(); - ViewModel.ParentModel.IsModified = true; - } + return; } + + if (DataContext is MidiInputViewModel) + { + AssignMidiBinding(button.Name, CreateUnboundMidiBinding()); + } + else + { + AssignKeyboardBinding(button.Name, Key.Unbound); + } + } + + private void CancelCurrentAssignment(bool shouldUnbind = false) + { + if (_keyboardAssigner != null) + { + _keyboardAssigner.Cancel(shouldUnbind); + _keyboardAssigner = null; + } + + if (_midiToggleButton != null) + { + _shouldUnbindMidi = shouldUnbind; + _midiCancellationTokenSource?.Cancel(); + } + } + + private ToggleButton GetCurrentToggleButton() + { + return _keyboardAssigner?.ToggledButton ?? _midiToggleButton; + } + + private IGamepad GetSelectedGamepad() + { + return DataContext switch + { + KeyboardInputViewModel keyboardViewModel => keyboardViewModel.ParentModel.SelectedGamepad, + MidiInputViewModel midiViewModel => midiViewModel.ParentModel.SelectedGamepad, + _ => null, + }; + } + + private IGamepad GetCancelKeyboard() + { + return DataContext switch + { + KeyboardInputViewModel keyboardViewModel => keyboardViewModel.ParentModel.AvaloniaKeyboardDriver.GetGamepad("0"), + MidiInputViewModel midiViewModel => midiViewModel.ParentModel.AvaloniaKeyboardDriver.GetGamepad("0"), + _ => null, + }; + } + + private void AssignKeyboardBinding(string controlName, Key value) + { + if (DataContext is not KeyboardInputViewModel viewModel) + { + return; + } + + viewModel.ParentModel.IsModified = true; + + Dictionary buttonActions = new() + { + { "ButtonZl", () => viewModel.Config.ButtonZl = value }, + { "ButtonL", () => viewModel.Config.ButtonL = value }, + { "ButtonMinus", () => viewModel.Config.ButtonMinus = value }, + { "LeftStickButton", () => viewModel.Config.LeftStickButton = value }, + { "LeftStickUp", () => viewModel.Config.LeftStickUp = value }, + { "LeftStickDown", () => viewModel.Config.LeftStickDown = value }, + { "LeftStickRight", () => viewModel.Config.LeftStickRight = value }, + { "LeftStickLeft", () => viewModel.Config.LeftStickLeft = value }, + { "DpadUp", () => viewModel.Config.DpadUp = value }, + { "DpadDown", () => viewModel.Config.DpadDown = value }, + { "DpadLeft", () => viewModel.Config.DpadLeft = value }, + { "DpadRight", () => viewModel.Config.DpadRight = value }, + { "LeftButtonSr", () => viewModel.Config.LeftButtonSr = value }, + { "LeftButtonSl", () => viewModel.Config.LeftButtonSl = value }, + { "RightButtonSr", () => viewModel.Config.RightButtonSr = value }, + { "RightButtonSl", () => viewModel.Config.RightButtonSl = value }, + { "ButtonZr", () => viewModel.Config.ButtonZr = value }, + { "ButtonR", () => viewModel.Config.ButtonR = value }, + { "ButtonPlus", () => viewModel.Config.ButtonPlus = value }, + { "ButtonA", () => viewModel.Config.ButtonA = value }, + { "ButtonB", () => viewModel.Config.ButtonB = value }, + { "ButtonX", () => viewModel.Config.ButtonX = value }, + { "ButtonY", () => viewModel.Config.ButtonY = value }, + { "RightStickButton", () => viewModel.Config.RightStickButton = value }, + { "RightStickUp", () => viewModel.Config.RightStickUp = value }, + { "RightStickDown", () => viewModel.Config.RightStickDown = value }, + { "RightStickRight", () => viewModel.Config.RightStickRight = value }, + { "RightStickLeft", () => viewModel.Config.RightStickLeft = value }, + }; + + if (buttonActions.TryGetValue(controlName, out Action action)) + { + action(); + } + } + + private void AssignMidiBinding(string controlName, MidiBinding value) + { + if (DataContext is not MidiInputViewModel viewModel) + { + return; + } + + viewModel.ParentModel.IsModified = true; + + Dictionary buttonActions = new() + { + { "ButtonZl", () => viewModel.Config.ButtonZl = value }, + { "ButtonL", () => viewModel.Config.ButtonL = value }, + { "ButtonMinus", () => viewModel.Config.ButtonMinus = value }, + { "LeftStickButton", () => viewModel.Config.LeftStickButton = value }, + { "LeftStickUp", () => viewModel.Config.LeftStickUp = value }, + { "LeftStickDown", () => viewModel.Config.LeftStickDown = value }, + { "LeftStickRight", () => viewModel.Config.LeftStickRight = value }, + { "LeftStickLeft", () => viewModel.Config.LeftStickLeft = value }, + { "DpadUp", () => viewModel.Config.DpadUp = value }, + { "DpadDown", () => viewModel.Config.DpadDown = value }, + { "DpadLeft", () => viewModel.Config.DpadLeft = value }, + { "DpadRight", () => viewModel.Config.DpadRight = value }, + { "LeftButtonSr", () => viewModel.Config.LeftButtonSr = value }, + { "LeftButtonSl", () => viewModel.Config.LeftButtonSl = value }, + { "RightButtonSr", () => viewModel.Config.RightButtonSr = value }, + { "RightButtonSl", () => viewModel.Config.RightButtonSl = value }, + { "ButtonZr", () => viewModel.Config.ButtonZr = value }, + { "ButtonR", () => viewModel.Config.ButtonR = value }, + { "ButtonPlus", () => viewModel.Config.ButtonPlus = value }, + { "ButtonA", () => viewModel.Config.ButtonA = value }, + { "ButtonB", () => viewModel.Config.ButtonB = value }, + { "ButtonX", () => viewModel.Config.ButtonX = value }, + { "ButtonY", () => viewModel.Config.ButtonY = value }, + { "RightStickButton", () => viewModel.Config.RightStickButton = value }, + { "RightStickUp", () => viewModel.Config.RightStickUp = value }, + { "RightStickDown", () => viewModel.Config.RightStickDown = value }, + { "RightStickRight", () => viewModel.Config.RightStickRight = value }, + { "RightStickLeft", () => viewModel.Config.RightStickLeft = value }, + }; + + if (buttonActions.TryGetValue(controlName, out Action action)) + { + action(); + } + } + + private static MidiBinding CreateUnboundMidiBinding() + { + return new MidiBinding + { + Kind = MidiBindingKind.Unbound, + Threshold = 1, + }; } protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) { base.OnDetachedFromVisualTree(e); - _currentAssigner?.Cancel(); - _currentAssigner = null; + + CancelCurrentAssignment(); } } } diff --git a/src/Ryujinx/UI/Windows/MainWindow.axaml.cs b/src/Ryujinx/UI/Windows/MainWindow.axaml.cs index e7934f38a..268dbb97a 100644 --- a/src/Ryujinx/UI/Windows/MainWindow.axaml.cs +++ b/src/Ryujinx/UI/Windows/MainWindow.axaml.cs @@ -29,6 +29,7 @@ using Ryujinx.HLE.FileSystem; using Ryujinx.HLE.HOS; using Ryujinx.HLE.HOS.Services.Account.Acc; using Ryujinx.Input.HLE; +using Ryujinx.Input.Midi; using Ryujinx.Input.SDL3; using System; using System.Collections.Generic; @@ -105,7 +106,7 @@ namespace Ryujinx.Ava.UI.Windows if (Program.PreviewerDetached) { - InputManager = new InputManager(new AvaloniaKeyboardDriver(this), new SDL3GamepadDriver()); + InputManager = new InputManager(new AvaloniaKeyboardDriver(this), new SDL3GamepadDriver(), new MidiGamepadDriver()); _ = this.GetObservable(IsActiveProperty).Subscribe(it => ViewModel.IsActive = it); this.ScalingChanged += OnScalingChanged;