Compare commits

13 Commits

13 changed files with 513 additions and 332 deletions

View File

@@ -208,7 +208,7 @@ If you develop a new program, and you want it to be of the greatest possible use
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found.
gpl-test midi-hid
Copyright (C) 2025 datalore Copyright (C) 2025 datalore
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
@@ -221,7 +221,7 @@ Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:
gpl-test Copyright (C) 2025 datalore midi-hid Copyright (C) 2025 datalore
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.

View File

@@ -2,11 +2,49 @@
This software allows mapping and translating of MIDI commands to HID inputs on Linux. This software allows mapping and translating of MIDI commands to HID inputs on Linux.
## Known issues ## Installation and Usage
The midi library used seems to recognise NoteOff messages as NoteOn messages. However, they can still be recognised by checking the velocity, which is always 0 in NoteOff messages. A workaround has been implemented. Install with
```bash
go install github.com/d4t4l0r3/midi-hid@latest
```
and run it with `midi-hid`. If no config file is specified, it reads `~/.config/midi-hid/config.yaml`.
Every configured midi controller will be represented by a virtual gamepad, and the inputs will be translated until SIGINT is received.
## Configuration
See `example-config.yaml` For an annotated configuration file.
The valid names for buttons are:
| Button name | Description |
| ------------ | -------------------------------- |
| `north` | E.g. Y on XBox or triangle on PS |
| `east` | E.g. B on XBox or circle on PS |
| `south` | E.g. A on XBox or X on PS |
| `west` | E.g. X on XBox or square on PS |
| `l1` | Left bumper |
| `l2` | Left trigger |
| `l3` | Left stick pressed down |
| `r1` | Right bumper |
| `r2` | Right trigger |
| `r3` | Right stick pressed down |
| `select` | Select, or Back on XBox |
| `start` | Start button |
| `dpad-up` | Directional pad up |
| `dpad-down` | Directional pad down |
| `dpad-left` | Directional pad left |
| `dpad-right` | Directional pad right |
Valid axis are `left-x`, `left-y`, `right-x` and `right-y`.
### Finding the MIDI channel and note / controller
Many vendors provide official documentation on the MIDI commands sent by their devices, but if you are unable to find them, you can use `aseqdump -p <port>` to print all MIDI messages sent by your device. Simply interact with the controls you want to map and you will see the corresponding messages.
## Third-party libraries ## Third-party libraries
- <https://github.com/bendahl/uinput> - <https://github.com/bendahl/uinput>
- <https://gitlab.com/gomidi/midi> - <https://gitlab.com/gomidi/midi>
- <https://github.com/charmbracelet/log>

148
config.go
View File

@@ -1,148 +0,0 @@
package main
import (
"fmt"
"os"
"github.com/goccy/go-yaml"
"github.com/bendahl/uinput"
)
type Config struct {
Controller []ControllerConfig `yaml:"controller"`
}
type ControllerConfig struct {
PortName string `yaml:"portName"`
VendorID uint16 `yaml:"vendorID"`
ProductID uint16 `yaml:"productID"`
Mappings []MappingConfig `yaml:"mappings"`
}
type MappingConfig struct {
Comment string `yaml:"comment"`
Type MappingType `yaml:"type"`
MidiChannel uint8 `yaml:"midiChannel"`
MidiKey uint8 `yaml:"midiKey"`
MidiController uint8 `yaml:"midiController"`
Button ButtonName `yaml:"button"`
Axis AxisName `yaml:"axis"`
IsSigned bool `yaml:"isSigned"`
}
type MappingType string
type ButtonName string
type AxisName string
const (
ButtonMappingType MappingType = "button"
ControlMappingType MappingType = "control"
ButtonNorth ButtonName = "north"
ButtonEast ButtonName = "east"
ButtonSouth ButtonName = "south"
ButtonWest ButtonName = "west"
AxisLeftX AxisName = "left-x"
AxisLeftY AxisName = "left-y"
AxisRightX AxisName = "right-x"
AxisRightY AxisName = "right-y"
)
func ParseConfig(path string) (Config, error) {
var config Config
buffer, err := os.ReadFile(path)
if err != nil {
return config, err
}
err = yaml.Unmarshal(buffer, &config)
if err != nil {
return config, err
}
return config, nil
}
func (config Config) Construct() (ControllerList, error) {
var controllerList ControllerList
for _, controllerConfig := range config.Controller {
actualController, err := controllerConfig.Construct()
if err != nil {
return nil, err
}
controllerList = append(controllerList, actualController)
}
return controllerList, nil
}
func (cc ControllerConfig) Construct() (*Controller, error) {
actualController, err := NewController(cc.PortName, cc.VendorID, cc.ProductID)
if err != nil {
return actualController, err
}
for _, mappingConfig := range cc.Mappings {
actualMapping, err := mappingConfig.Construct()
if err != nil {
return nil, err
}
actualController.AddMapping(actualMapping)
}
return actualController, nil
}
func (mc MappingConfig) Construct() (Mapping, error) {
switch mc.Type {
case ButtonMappingType:
button, err := mc.Button.Construct()
if err != nil {
return ButtonMapping{}, err
}
return ButtonMapping{mc.Comment, mc.MidiChannel, mc.MidiKey, button}, nil
case ControlMappingType:
axis, err := mc.Axis.Construct()
if err != nil {
return ControlMapping{}, err
}
return ControlMapping{mc.Comment, mc.MidiChannel, mc.MidiController, axis, mc.IsSigned}, nil
default:
return ButtonMapping{}, fmt.Errorf("Invalid mapping type")
}
}
func (bn ButtonName) Construct() (int, error) {
switch bn {
case ButtonNorth:
return uinput.ButtonNorth, nil
case ButtonEast:
return uinput.ButtonEast, nil
case ButtonSouth:
return uinput.ButtonSouth, nil
case ButtonWest:
return uinput.ButtonWest, nil
default:
return -1, fmt.Errorf("Invalid button name \"%s\"", bn)
}
}
func (an AxisName) Construct() (ControllerAxis, error) {
switch an {
case AxisLeftX:
return LeftX, nil
case AxisLeftY:
return LeftY, nil
case AxisRightX:
return RightX, nil
case AxisRightY:
return RightY, nil
default:
return -1, fmt.Errorf("Invalid axis name \"%s\"", an)
}
}

View File

@@ -1,47 +0,0 @@
controller:
- portName: DJControl Inpulse 500 MIDI 1
vendorID: 0x45e
productID: 0x285
mappings:
- comment: Play left
type: button
midiChannel: 1
midiKey: 7
button: west
- comment: Hotcue 1 left
type: button
midiChannel: 6
midiKey: 0
button: north
- comment: Hotcue 5 left
type: button
midiChannel: 6
midiKey: 4
button: south
- comment: Play right
type: button
midiChannel: 2
midiKey: 7
button: east
- comment: Volume left
type: control
midiChannel: 1
midiController: 0
axis: left-y
- comment: Volume right
type: control
midiChannel: 2
midiController: 0
axis: right-y
- comment: Filter left
type: control
midiChannel: 1
midiController: 1
axis: left-x
isSigned: true
- comment: Filter right
type: control
midiChannel: 2
midiController: 1
axis: right-x
isSigned: true

207
config/config.go Normal file
View File

@@ -0,0 +1,207 @@
package config
import (
"fmt"
"os"
"github.com/d4t4l0r3/midi-hid/translation"
"github.com/goccy/go-yaml"
"github.com/bendahl/uinput"
"github.com/charmbracelet/log"
)
// Config is the root type of a config, consisting of an arbitrary number of controller configs.
type Config struct {
Controller []ControllerConfig `yaml:"controller"`
}
// A ControllerConfig represents the data needed to later construct a Controller object.
type ControllerConfig struct {
PortName string `yaml:"portName"`
VendorID uint16 `yaml:"vendorID"`
ProductID uint16 `yaml:"productID"`
Mappings []MappingConfig `yaml:"mappings"`
}
// A MappingConfig consists of all data possibly needed to construct a mapping, both button and control.
type MappingConfig struct {
Comment string `yaml:"comment"`
Type MappingType `yaml:"type"`
MidiChannel uint8 `yaml:"midiChannel"`
MidiKey uint8 `yaml:"midiKey"`
MidiController uint8 `yaml:"midiController"`
Button ButtonName `yaml:"button"`
Axis AxisName `yaml:"axis"`
IsSigned bool `yaml:"isSigned"`
Deadzone float64 `yaml:"deadzone"`
}
type MappingType string
type ButtonName string
type AxisName string
const (
ButtonMappingType MappingType = "button"
ControlMappingType MappingType = "control"
ButtonNorth ButtonName = "north"
ButtonEast ButtonName = "east"
ButtonSouth ButtonName = "south"
ButtonWest ButtonName = "west"
ButtonL1 ButtonName = "l1"
ButtonL2 ButtonName = "l2"
ButtonL3 ButtonName = "l3"
ButtonR1 ButtonName = "r1"
ButtonR2 ButtonName = "r2"
ButtonR3 ButtonName = "r3"
ButtonSelect ButtonName = "select"
ButtonStart ButtonName = "start"
ButtonDpadUp ButtonName = "dpad-up"
ButtonDpadDown ButtonName = "dpad-down"
ButtonDpadLeft ButtonName = "dpad-left"
ButtonDpadRight ButtonName = "dpad-right"
AxisLeftX AxisName = "left-x"
AxisLeftY AxisName = "left-y"
AxisRightX AxisName = "right-x"
AxisRightY AxisName = "right-y"
)
// ParseConfig takes the path to a config file and returns the parsed Config object,
// or an error if thrown.
func ParseConfig(path string) (Config, error) {
var config Config
buffer, err := os.ReadFile(os.ExpandEnv(path))
if err != nil {
return config, err
}
err = yaml.Unmarshal(buffer, &config)
if err != nil {
return config, err
}
return config, nil
}
// Construct iterates over all ControllerConfigs and constructs the Controller objects.
// In case of a failure, it aborts and returns an error.
func (config Config) Construct() (translation.ControllerList, error) {
var controllerList translation.ControllerList
for _, controllerConfig := range config.Controller {
actualController, err := controllerConfig.Construct()
if err != nil {
return nil, err
}
controllerList = append(controllerList, actualController)
}
return controllerList, nil
}
// Construct builds a Controller object and its corresponding mappings.
// Aborts and returns an error if the midi port was not found or one of
// the Mappings is invalid.
func (cc ControllerConfig) Construct() (*translation.Controller, error) {
actualController, err := translation.NewController(cc.PortName, cc.VendorID, cc.ProductID)
if err != nil {
return actualController, err
}
for _, mappingConfig := range cc.Mappings {
actualMapping, err := mappingConfig.Construct()
if err != nil {
return nil, err
}
actualController.AddMapping(actualMapping)
}
return actualController, nil
}
// Construct builds the Mapping object. Returns an error if config is invalid.
func (mc MappingConfig) Construct() (translation.Mapping, error) {
switch mc.Type {
case ButtonMappingType:
button, err := mc.Button.Construct()
if err != nil {
return translation.ButtonMapping{}, err
}
log.Debug("Parsed button mapping", "comment", mc.Comment, "midiChannel", mc.MidiChannel, "midiKey", mc.MidiKey, "button", button)
return translation.ButtonMapping{mc.Comment, mc.MidiChannel, mc.MidiKey, button}, nil
case ControlMappingType:
axis, err := mc.Axis.Construct()
if err != nil {
return translation.ControlMapping{}, err
}
log.Debug("Parsed control mapping", "comment", mc.Comment, "midiChannel", mc.MidiChannel, "midiController", mc.MidiController, "axis", axis, "isSigned", mc.IsSigned, "deadzone", mc.Deadzone)
return translation.ControlMapping{mc.Comment, mc.MidiChannel, mc.MidiController, axis, mc.IsSigned, mc.Deadzone}, nil
default:
return translation.ButtonMapping{}, fmt.Errorf("Invalid mapping type")
}
}
// Construct converts a ButtonName to its corresponding key code, or returns an error if the
// name is unknown.
func (bn ButtonName) Construct() (int, error) {
switch bn {
case ButtonNorth:
return uinput.ButtonNorth, nil
case ButtonEast:
return uinput.ButtonEast, nil
case ButtonSouth:
return uinput.ButtonSouth, nil
case ButtonWest:
return uinput.ButtonWest, nil
case ButtonL1:
return uinput.ButtonBumperLeft, nil
case ButtonL2:
return uinput.ButtonTriggerLeft, nil
case ButtonL3:
return uinput.ButtonThumbLeft, nil
case ButtonR1:
return uinput.ButtonBumperRight, nil
case ButtonR2:
return uinput.ButtonTriggerRight, nil
case ButtonR3:
return uinput.ButtonThumbRight, nil
case ButtonSelect:
return uinput.ButtonSelect, nil
case ButtonStart:
return uinput.ButtonStart, nil
case ButtonDpadUp:
return uinput.ButtonDpadUp, nil
case ButtonDpadDown:
return uinput.ButtonDpadDown, nil
case ButtonDpadLeft:
return uinput.ButtonDpadLeft, nil
case ButtonDpadRight:
return uinput.ButtonDpadRight, nil
default:
return -1, fmt.Errorf("Invalid button name \"%s\"", bn)
}
}
// Construct converts an AxisName into the internal representation for a ControllerAxis.
// Returns an error if AxisName is invalid.
func (an AxisName) Construct() (translation.ControllerAxis, error) {
switch an {
case AxisLeftX:
return translation.LeftX, nil
case AxisLeftY:
return translation.LeftY, nil
case AxisRightX:
return translation.RightX, nil
case AxisRightY:
return translation.RightY, nil
default:
return -1, fmt.Errorf("Invalid axis name \"%s\"", an)
}
}

22
example-config.yaml Normal file
View File

@@ -0,0 +1,22 @@
controller:
- portName: DJControl Inpulse 500 MIDI 1 # name of the MIDI port to read from. You can find it with aseqdump -l
vendorID: 0x45e # vendor ID for the virtual gamepad. Default is 0x45e. See https://gist.github.com/nondebug/aec93dff7f0f1969f4cc2291b24a3171
productID: 0x285 # product ID for the virtual gamepad. Default is 0x285. See https://gist.github.com/nondebug/aec93dff7f0f1969f4cc2291b24a3171
mappings:
- comment: Play left # This is optional, but it can increase comprehensibility. Will also be used in debug output.
type: button # Either button or control. A button can be pressed or released, a control represents a range of values.
midiChannel: 1 # MIDI channel to listen to
midiKey: 7 # MIDI note representing the button
button: west # Name of the gamepad button this should be mapped to. For valid names, see README.md
- comment: Filter left
type: control
midiChannel: 1
midiController: 1 # MIDI controller representing the physical control
axis: left-x # Axis on the virtual gamepad this should be mapped to. For valid names, see README.md
isSigned: true # Whether the controller axis should range from -1 to 1 or from 0 to 1. Default is false
deadzone: 0.01 # Number between 0 and 1. In this case, all values between -0.01 and 0.01 will be clamped to 0. Default is 0.0
- comment: Volume left
type: control
midiChannel: 1
midiController: 0
axis: left-y

21
go.mod
View File

@@ -1,9 +1,28 @@
module datalore/midi-hid module github.com/d4t4l0r3/midi-hid
go 1.24.5 go 1.24.5
require ( require (
github.com/bendahl/uinput v1.7.0 github.com/bendahl/uinput v1.7.0
github.com/charmbracelet/log v0.4.2
github.com/goccy/go-yaml v1.18.0 github.com/goccy/go-yaml v1.18.0
gitlab.com/gomidi/midi/v2 v2.3.16 gitlab.com/gomidi/midi/v2 v2.3.16
) )
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.8.0 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/go-logfmt/logfmt v0.6.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
golang.org/x/sys v0.30.0 // indirect
)

42
go.sum
View File

@@ -1,6 +1,48 @@
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/bendahl/uinput v1.7.0 h1:nA4fm8Wu8UYNOPykIZm66nkWEyvxzfmJ8YC02PM40jg= github.com/bendahl/uinput v1.7.0 h1:nA4fm8Wu8UYNOPykIZm66nkWEyvxzfmJ8YC02PM40jg=
github.com/bendahl/uinput v1.7.0/go.mod h1:Np7w3DINc9wB83p12fTAM3DPPhFnAKP0WTXRqCQJ6Z8= github.com/bendahl/uinput v1.7.0/go.mod h1:Np7w3DINc9wB83p12fTAM3DPPhFnAKP0WTXRqCQJ6Z8=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig=
github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw=
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
gitlab.com/gomidi/midi/v2 v2.3.16 h1:yufWSENyjnJ4LFQa9BerzUm4E4aLfTyzw5nmnCteO0c= gitlab.com/gomidi/midi/v2 v2.3.16 h1:yufWSENyjnJ4LFQa9BerzUm4E4aLfTyzw5nmnCteO0c=
gitlab.com/gomidi/midi/v2 v2.3.16/go.mod h1:jDpP4O4skYi+7iVwt6Zyp18bd2M4hkjtMuw2cmgKgfw= gitlab.com/gomidi/midi/v2 v2.3.16/go.mod h1:jDpP4O4skYi+7iVwt6Zyp18bd2M4hkjtMuw2cmgKgfw=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

24
main.go
View File

@@ -1,10 +1,13 @@
package main package main
import ( import (
"log" "flag"
"os" "os"
"os/signal" "os/signal"
"github.com/d4t4l0r3/midi-hid/config"
"github.com/charmbracelet/log"
"gitlab.com/gomidi/midi/v2" "gitlab.com/gomidi/midi/v2"
) )
@@ -19,9 +22,22 @@ func must[T any](obj T, err error) T {
func main() { func main() {
defer midi.CloseDriver() defer midi.CloseDriver()
log.Println("Starting...") var (
config := must(ParseConfig("config.yaml")) configPath string
controllerList := must(config.Construct()) printDebugMsgs bool
)
flag.StringVar(&configPath, "f", "$HOME/.config/midi-hid/config.yaml", "Config file")
flag.BoolVar(&printDebugMsgs, "debug", false, "Print debug messages")
flag.Parse()
if printDebugMsgs {
log.SetLevel(log.DebugLevel)
}
log.Info("Starting...")
conf := must(config.ParseConfig(configPath))
controllerList := must(conf.Construct())
defer controllerList.Stop() defer controllerList.Stop()
// wait for SIGINT // wait for SIGINT

View File

@@ -1,123 +0,0 @@
package main
import (
"fmt"
"log"
"gitlab.com/gomidi/midi/v2"
"github.com/bendahl/uinput"
)
type Mapping interface {
Is(midi.Message) bool
TriggerIfMatch(midi.Message, uinput.Gamepad) error
Comment() string
}
type ButtonMapping struct {
comment string
midiChannel uint8
midiKey uint8
gamepadKey int
}
func (m ButtonMapping) Is(msg midi.Message) bool {
var channel, key uint8
switch {
case msg.GetNoteOn(&channel, &key, nil), msg.GetNoteOff(&channel, &key, nil):
return (m.midiChannel == channel && m.midiKey == key)
default:
return false
}
}
func (m ButtonMapping) TriggerIfMatch(msg midi.Message, virtGamepad uinput.Gamepad) error {
if m.Is(msg) {
var velocity uint8
msg.GetNoteOn(nil, nil, &velocity)
switch msg.Type() {
case midi.NoteOnMsg:
if velocity != 0 {
log.Printf("%s: Button down\n", m.comment)
return virtGamepad.ButtonDown(m.gamepadKey)
}
fallthrough // if reached here, velocity is 0 -> NoteOff
case midi.NoteOffMsg:
log.Printf("%s: Button up\n", m.comment)
return virtGamepad.ButtonUp(m.gamepadKey)
default:
return fmt.Errorf("Invalid message type triggered ButtonMapping")
}
}
return nil
}
func (m ButtonMapping) Comment() string {
return m.comment
}
type ControllerAxis int
const (
LeftX ControllerAxis = iota
LeftY
RightX
RightY
)
type ControlMapping struct {
comment string
midiChannel uint8
midiController uint8
axis ControllerAxis
isSigned bool
}
func (m ControlMapping) Is(msg midi.Message) bool {
var channel, controller uint8
if msg.GetControlChange(&channel, &controller, nil) {
return (m.midiChannel == channel && m.midiController == controller)
} else {
return false
}
}
func (m ControlMapping) TriggerIfMatch(msg midi.Message, virtGamepad uinput.Gamepad) error {
if m.Is(msg) {
var (
valueAbsolute uint8
valueNormalised float32
)
msg.GetControlChange(nil, nil, &valueAbsolute)
// value is 0-127, normalise
valueNormalised = float32(valueAbsolute) / 127
if m.isSigned {
valueNormalised *= 2
valueNormalised -= 1
}
log.Printf("%s: value %v\n", m.comment, valueNormalised)
switch m.axis {
case LeftX:
return virtGamepad.LeftStickMoveX(valueNormalised)
case LeftY:
return virtGamepad.LeftStickMoveY(valueNormalised)
case RightX:
return virtGamepad.RightStickMoveX(valueNormalised)
case RightY:
return virtGamepad.RightStickMoveY(valueNormalised)
}
}
return nil
}
func (m ControlMapping) Comment() string {
return m.comment
}

View File

@@ -1,20 +1,23 @@
package main package translation
import ( import (
"log" "github.com/charmbracelet/log"
"gitlab.com/gomidi/midi/v2" "gitlab.com/gomidi/midi/v2"
"github.com/bendahl/uinput" "github.com/bendahl/uinput"
) )
// A ControllerList is a list of controllers. Duh.
type ControllerList []*Controller type ControllerList []*Controller
// Stop iterates over all Controller objects and Stops their update loops and MIDI connections.
// Always call this for a clean shutdown. Meant to be deferred.
func (cl ControllerList) Stop() { func (cl ControllerList) Stop() {
for _, controller := range cl { for _, controller := range cl {
controller.Stop() controller.Stop()
} }
} }
// A Controller object manages the translation from MIDI to uinput.
type Controller struct { type Controller struct {
midiInput *MidiInput midiInput *MidiInput
mappings []Mapping mappings []Mapping
@@ -22,7 +25,14 @@ type Controller struct {
virtGamepad uinput.Gamepad virtGamepad uinput.Gamepad
} }
// NewController builds a new Controller object reading from the MIDI port specified by portName,
// and registers a virtual uinput-Gamepad using vendorID and productID.
func NewController(portName string, vendorID, productID uint16) (*Controller, error) { func NewController(portName string, vendorID, productID uint16) (*Controller, error) {
if vendorID == 0 && productID == 0 {
// if no IDs were defined, imitate XBox 360 controller
vendorID = 0x45e
productID = 0x285
}
midiInput, err := NewMidiInput(portName) midiInput, err := NewMidiInput(portName)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -51,10 +61,12 @@ func NewController(portName string, vendorID, productID uint16) (*Controller, er
return controller, nil return controller, nil
} }
// AddMapping adds a mapping to the Controller.
func (c *Controller) AddMapping(mapping Mapping) { func (c *Controller) AddMapping(mapping Mapping) {
c.mappings = append(c.mappings, mapping) c.mappings = append(c.mappings, mapping)
} }
// Stop quits the update loop and terminates all corresponding connections.
func (c Controller) Stop() { func (c Controller) Stop() {
c.midiInput.Stop() c.midiInput.Stop()
c.abortChan <- struct{}{} c.abortChan <- struct{}{}
@@ -65,7 +77,7 @@ func (c Controller) update(msg midi.Message) {
for _, mapping := range c.mappings { for _, mapping := range c.mappings {
err := mapping.TriggerIfMatch(msg, c.virtGamepad) err := mapping.TriggerIfMatch(msg, c.virtGamepad)
if err != nil { if err != nil {
log.Printf("Error in Mapping \"%s\": %v\n", mapping.Comment(), err) log.Errorf("Error in Mapping \"%s\": %v", mapping.Comment(), err)
} }
} }
} }

139
translation/mapping.go Normal file
View File

@@ -0,0 +1,139 @@
package translation
import (
"fmt"
"math"
"github.com/charmbracelet/log"
"gitlab.com/gomidi/midi/v2"
"github.com/bendahl/uinput"
)
// A Mapping is an interface for all types of Mappings.
type Mapping interface {
Is(midi.Message) bool
TriggerIfMatch(midi.Message, uinput.Gamepad) error
Comment() string
}
// A ButtonMapping maps a MIDI Note to a gamepad button.
type ButtonMapping struct {
CommentStr string
MidiChannel uint8
MidiKey uint8
GamepadKey int
}
// Is checks if the MIDI message msg triggers this Mapping, without actually triggering it.
func (m ButtonMapping) Is(msg midi.Message) bool {
var channel, key uint8
switch {
case msg.GetNoteOn(&channel, &key, nil), msg.GetNoteOff(&channel, &key, nil):
return (m.MidiChannel == channel && m.MidiKey == key)
default:
return false
}
}
// TriggerIfMatch checks if the MIDI message msg triggers this Mapping, and if so,
// sends the corresponding input to virtGamepad.
func (m ButtonMapping) TriggerIfMatch(msg midi.Message, virtGamepad uinput.Gamepad) error {
if m.Is(msg) {
var velocity uint8
msg.GetNoteOn(nil, nil, &velocity)
switch msg.Type() {
case midi.NoteOnMsg:
if velocity != 0 {
log.Debug(m.CommentStr, "status", "down")
return virtGamepad.ButtonDown(m.GamepadKey)
}
fallthrough // if reached here, velocity is 0 -> NoteOff
case midi.NoteOffMsg:
log.Debug(m.CommentStr, "status", "up")
return virtGamepad.ButtonUp(m.GamepadKey)
default:
return fmt.Errorf("Invalid message type triggered ButtonMapping")
}
}
return nil
}
// Comment returns the Mappings comment.
func (m ButtonMapping) Comment() string {
return m.CommentStr
}
type ControllerAxis int
const (
LeftX ControllerAxis = iota
LeftY
RightX
RightY
)
type ControlMapping struct {
CommentStr string
MidiChannel uint8
MidiController uint8
Axis ControllerAxis
IsSigned bool
Deadzone float64
}
// Is checks if the MIDI message msg triggers this Mapping, without actually triggering it.
func (m ControlMapping) Is(msg midi.Message) bool {
var channel, controller uint8
if msg.GetControlChange(&channel, &controller, nil) {
return (m.MidiChannel == channel && m.MidiController == controller)
} else {
return false
}
}
// TriggerIfMatch checks if the MIDI message msg triggers this Mapping, and if so,
// sends the corresponding input to virtGamepad.
func (m ControlMapping) TriggerIfMatch(msg midi.Message, virtGamepad uinput.Gamepad) error {
if m.Is(msg) {
var (
valueAbsolute uint8
valueNormalised float64
)
msg.GetControlChange(nil, nil, &valueAbsolute)
// value is 0-127, normalise
valueNormalised = float64(valueAbsolute) / 127
if m.IsSigned {
valueNormalised *= 2
valueNormalised -= 1
}
if math.Abs(valueNormalised) < m.Deadzone {
valueNormalised = 0
}
log.Debug(m.CommentStr, "value", valueNormalised, "deadzone", m.Deadzone)
switch m.Axis {
case LeftX:
return virtGamepad.LeftStickMoveX(float32(valueNormalised))
case LeftY:
return virtGamepad.LeftStickMoveY(float32(valueNormalised))
case RightX:
return virtGamepad.RightStickMoveX(float32(valueNormalised))
case RightY:
return virtGamepad.RightStickMoveY(float32(valueNormalised))
}
}
return nil
}
// Comment returns the Mappings comment.
func (m ControlMapping) Comment() string {
return m.CommentStr
}

View File

@@ -1,4 +1,4 @@
package main package translation
import ( import (
"gitlab.com/gomidi/midi/v2" "gitlab.com/gomidi/midi/v2"
@@ -6,12 +6,16 @@ import (
_ "gitlab.com/gomidi/midi/v2/drivers/rtmididrv" _ "gitlab.com/gomidi/midi/v2/drivers/rtmididrv"
) )
// A MidiInput represents a MIDI input and provides a channel to read incoming
// Messages, as well as a Stop function to terminate the connection.
type MidiInput struct { type MidiInput struct {
input drivers.In input drivers.In
Messages chan midi.Message Messages chan midi.Message
Stop func() Stop func()
} }
// NewMidiInput initialises a MidiInput object connected to the MIDI port specified
// by portName. Retuns an error if the connection fails.
func NewMidiInput(portName string) (*MidiInput, error) { func NewMidiInput(portName string) (*MidiInput, error) {
input, err := midi.FindInPort(portName) input, err := midi.FindInPort(portName)
if err != nil { if err != nil {