diff --git a/README.md b/README.md index 86ebe2d..d75d7f3 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,8 @@ Install with go install git.datalore.sh/datalore/midi-hid@latest ``` -and run it with `midi-hid`. It reads `config.yaml` from its current working directory, creates the configured virtual gamepads and translates the inputs until SIGINT is received. +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. See the provided example config on how to configure your controller, it should be pretty self-explanatory. diff --git a/config.go b/config.go deleted file mode 100644 index e5b4ebd..0000000 --- a/config.go +++ /dev/null @@ -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) - } -} diff --git a/config.yaml b/config.yaml index 394180b..a9d3f63 100644 --- a/config.yaml +++ b/config.yaml @@ -39,9 +39,11 @@ controller: midiController: 1 axis: left-x isSigned: true + deadzone: 0.01 - comment: Filter right type: control midiChannel: 2 midiController: 1 axis: right-x isSigned: true + deadzone: 0.01 diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..ade05ab --- /dev/null +++ b/config/config.go @@ -0,0 +1,207 @@ +package config + +import ( + "fmt" + "os" + + "git.datalore.sh/datalore/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) + } +} diff --git a/go.mod b/go.mod index afd0b49..478140c 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,25 @@ go 1.24.5 require ( github.com/bendahl/uinput v1.7.0 + github.com/charmbracelet/log v0.4.2 github.com/goccy/go-yaml v1.18.0 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 +) diff --git a/go.sum b/go.sum index e6c8a8a..6a034b6 100644 --- a/go.sum +++ b/go.sum @@ -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/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/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/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= diff --git a/main.go b/main.go index a993714..bc6fd14 100644 --- a/main.go +++ b/main.go @@ -1,10 +1,13 @@ package main import ( - "log" + "flag" "os" "os/signal" + "git.datalore.sh/datalore/midi-hid/config" + + "github.com/charmbracelet/log" "gitlab.com/gomidi/midi/v2" ) @@ -19,9 +22,22 @@ func must[T any](obj T, err error) T { func main() { defer midi.CloseDriver() - log.Println("Starting...") - config := must(ParseConfig("config.yaml")) - controllerList := must(config.Construct()) + var ( + configPath string + 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() // wait for SIGINT diff --git a/mapping.go b/mapping.go deleted file mode 100644 index 6909856..0000000 --- a/mapping.go +++ /dev/null @@ -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 -} diff --git a/controller.go b/translation/controller.go similarity index 60% rename from controller.go rename to translation/controller.go index 6d2fc68..c33cacb 100644 --- a/controller.go +++ b/translation/controller.go @@ -1,20 +1,23 @@ -package main +package translation import ( - "log" - + "github.com/charmbracelet/log" "gitlab.com/gomidi/midi/v2" "github.com/bendahl/uinput" ) +// A ControllerList is a list of controllers. Duh. 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() { for _, controller := range cl { controller.Stop() } } +// A Controller object manages the translation from MIDI to uinput. type Controller struct { midiInput *MidiInput mappings []Mapping @@ -22,7 +25,14 @@ type Controller struct { 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) { + if vendorID == 0 && productID == 0 { + // if no IDs were defined, imitate XBox 360 controller + vendorID = 0x45e + productID = 0x285 + } midiInput, err := NewMidiInput(portName) if err != nil { return nil, err @@ -51,10 +61,12 @@ func NewController(portName string, vendorID, productID uint16) (*Controller, er return controller, nil } +// AddMapping adds a mapping to the Controller. func (c *Controller) AddMapping(mapping Mapping) { c.mappings = append(c.mappings, mapping) } +// Stop quits the update loop and terminates all corresponding connections. func (c Controller) Stop() { c.midiInput.Stop() c.abortChan <- struct{}{} @@ -65,7 +77,7 @@ func (c Controller) update(msg midi.Message) { for _, mapping := range c.mappings { err := mapping.TriggerIfMatch(msg, c.virtGamepad) if err != nil { - log.Printf("Error in Mapping \"%s\": %v\n", mapping.Comment(), err) + log.Errorf("Error in Mapping \"%s\": %v", mapping.Comment(), err) } } } diff --git a/translation/mapping.go b/translation/mapping.go new file mode 100644 index 0000000..9867695 --- /dev/null +++ b/translation/mapping.go @@ -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 +} diff --git a/midi.go b/translation/midi.go similarity index 69% rename from midi.go rename to translation/midi.go index 47ee925..b06589c 100644 --- a/midi.go +++ b/translation/midi.go @@ -1,4 +1,4 @@ -package main +package translation import ( "gitlab.com/gomidi/midi/v2" @@ -6,12 +6,16 @@ import ( _ "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 { input drivers.In Messages chan midi.Message 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) { input, err := midi.FindInPort(portName) if err != nil {