From 15a37f3e8c5cd5f21794ed18da434c64b9f833f2 Mon Sep 17 00:00:00 2001 From: datalore Date: Wed, 6 Aug 2025 11:00:18 +0200 Subject: [PATCH 1/8] feat(config): Now expanding env vars before reading files --- config.go | 2 +- main.go | 12 +++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/config.go b/config.go index e5b4ebd..6b47dfb 100644 --- a/config.go +++ b/config.go @@ -50,7 +50,7 @@ const ( func ParseConfig(path string) (Config, error) { var config Config - buffer, err := os.ReadFile(path) + buffer, err := os.ReadFile(os.ExpandEnv(path)) if err != nil { return config, err } diff --git a/main.go b/main.go index a993714..3cca74f 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + "flag" "log" "os" "os/signal" @@ -19,8 +20,17 @@ func must[T any](obj T, err error) T { func main() { defer midi.CloseDriver() + 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() + log.Println("Starting...") - config := must(ParseConfig("config.yaml")) + config := must(ParseConfig(configPath)) controllerList := must(config.Construct()) defer controllerList.Stop() From 09e7c36571792b4e025abd797674350ad6112c7e Mon Sep 17 00:00:00 2001 From: datalore Date: Wed, 6 Aug 2025 11:15:57 +0200 Subject: [PATCH 2/8] feat(logs): Improved logging --- controller.go | 5 ++--- go.mod | 19 +++++++++++++++++++ go.sum | 42 ++++++++++++++++++++++++++++++++++++++++++ main.go | 8 ++++++-- mapping.go | 8 ++++---- 5 files changed, 73 insertions(+), 9 deletions(-) diff --git a/controller.go b/controller.go index 6d2fc68..6f231ca 100644 --- a/controller.go +++ b/controller.go @@ -1,8 +1,7 @@ package main import ( - "log" - + "github.com/charmbracelet/log" "gitlab.com/gomidi/midi/v2" "github.com/bendahl/uinput" ) @@ -65,7 +64,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/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 3cca74f..4a1c6b9 100644 --- a/main.go +++ b/main.go @@ -2,10 +2,10 @@ package main import ( "flag" - "log" "os" "os/signal" + "github.com/charmbracelet/log" "gitlab.com/gomidi/midi/v2" ) @@ -29,7 +29,11 @@ func main() { flag.BoolVar(&printDebugMsgs, "debug", false, "Print debug messages") flag.Parse() - log.Println("Starting...") + if printDebugMsgs { + log.SetLevel(log.DebugLevel) + } + + log.Info("Starting...") config := must(ParseConfig(configPath)) controllerList := must(config.Construct()) defer controllerList.Stop() diff --git a/mapping.go b/mapping.go index 6909856..013b312 100644 --- a/mapping.go +++ b/mapping.go @@ -2,8 +2,8 @@ package main import ( "fmt" - "log" + "github.com/charmbracelet/log" "gitlab.com/gomidi/midi/v2" "github.com/bendahl/uinput" ) @@ -39,12 +39,12 @@ func (m ButtonMapping) TriggerIfMatch(msg midi.Message, virtGamepad uinput.Gamep switch msg.Type() { case midi.NoteOnMsg: if velocity != 0 { - log.Printf("%s: Button down\n", m.comment) + log.Debugf("%s: Button down", 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) + log.Debugf("%s: Button up", m.comment) return virtGamepad.ButtonUp(m.gamepadKey) default: return fmt.Errorf("Invalid message type triggered ButtonMapping") @@ -101,7 +101,7 @@ func (m ControlMapping) TriggerIfMatch(msg midi.Message, virtGamepad uinput.Game valueNormalised -= 1 } - log.Printf("%s: value %v\n", m.comment, valueNormalised) + log.Debugf("%s: value %v", m.comment, valueNormalised) switch m.axis { case LeftX: From 123b49d60332f3f1c4792d7f817665fbb1db211a Mon Sep 17 00:00:00 2001 From: datalore Date: Wed, 6 Aug 2025 21:12:41 +0200 Subject: [PATCH 3/8] feat(config): Added missing buttons --- config.go | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/config.go b/config.go index 6b47dfb..a7002ab 100644 --- a/config.go +++ b/config.go @@ -41,6 +41,18 @@ const ( 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" @@ -127,6 +139,30 @@ func (bn ButtonName) Construct() (int, error) { 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) } From 17361e888e8219ae143b2d635a5515bd5092a4c1 Mon Sep 17 00:00:00 2001 From: datalore Date: Wed, 6 Aug 2025 21:52:48 +0200 Subject: [PATCH 4/8] change(README): Updated section about config file --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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. From 8a7254a4678ea92c80f76126a8ec8794969a8de3 Mon Sep 17 00:00:00 2001 From: datalore Date: Wed, 6 Aug 2025 23:02:37 +0200 Subject: [PATCH 5/8] change(Controller): XBox 360 controller now default for vendor/productID --- controller.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/controller.go b/controller.go index 6f231ca..508da79 100644 --- a/controller.go +++ b/controller.go @@ -22,6 +22,11 @@ type Controller struct { } 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 From ff859d6bf7253584911d50c0da39538e0bbfe6af Mon Sep 17 00:00:00 2001 From: datalore Date: Sat, 9 Aug 2025 16:47:56 +0200 Subject: [PATCH 6/8] feat(deadzone): Implemented deadzone and improved debug logging verbosity --- config.go | 8 +++++++- config.yaml | 2 ++ mapping.go | 24 +++++++++++++++--------- 3 files changed, 24 insertions(+), 10 deletions(-) diff --git a/config.go b/config.go index a7002ab..62627ab 100644 --- a/config.go +++ b/config.go @@ -6,6 +6,7 @@ import ( "github.com/goccy/go-yaml" "github.com/bendahl/uinput" + "github.com/charmbracelet/log" ) type Config struct { @@ -28,6 +29,7 @@ type MappingConfig struct { Button ButtonName `yaml:"button"` Axis AxisName `yaml:"axis"` IsSigned bool `yaml:"isSigned"` + Deadzone float64 `yaml:"deadzone"` } type MappingType string @@ -116,6 +118,8 @@ func (mc MappingConfig) Construct() (Mapping, error) { return ButtonMapping{}, err } + log.Debug("Parsed button mapping", "comment", mc.Comment, "midiChannel", mc.MidiChannel, "midiKey", mc.MidiKey, "button", button) + return ButtonMapping{mc.Comment, mc.MidiChannel, mc.MidiKey, button}, nil case ControlMappingType: axis, err := mc.Axis.Construct() @@ -123,7 +127,9 @@ func (mc MappingConfig) Construct() (Mapping, error) { return ControlMapping{}, err } - return ControlMapping{mc.Comment, mc.MidiChannel, mc.MidiController, axis, mc.IsSigned}, nil + log.Debug("Parsed control mapping", "comment", mc.Comment, "midiChannel", mc.MidiChannel, "midiController", mc.MidiController, "axis", axis, "isSigned", mc.IsSigned, "deadzone", mc.Deadzone) + + return ControlMapping{mc.Comment, mc.MidiChannel, mc.MidiController, axis, mc.IsSigned, mc.Deadzone}, nil default: return ButtonMapping{}, fmt.Errorf("Invalid mapping type") } 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/mapping.go b/mapping.go index 013b312..a5b9ebe 100644 --- a/mapping.go +++ b/mapping.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "math" "github.com/charmbracelet/log" "gitlab.com/gomidi/midi/v2" @@ -39,12 +40,12 @@ func (m ButtonMapping) TriggerIfMatch(msg midi.Message, virtGamepad uinput.Gamep switch msg.Type() { case midi.NoteOnMsg: if velocity != 0 { - log.Debugf("%s: Button down", m.comment) + log.Debug(m.comment, "status", "down") return virtGamepad.ButtonDown(m.gamepadKey) } fallthrough // if reached here, velocity is 0 -> NoteOff case midi.NoteOffMsg: - log.Debugf("%s: Button up", m.comment) + log.Debug(m.comment, "status", "up") return virtGamepad.ButtonUp(m.gamepadKey) default: return fmt.Errorf("Invalid message type triggered ButtonMapping") @@ -73,6 +74,7 @@ type ControlMapping struct { midiController uint8 axis ControllerAxis isSigned bool + deadzone float64 } func (m ControlMapping) Is(msg midi.Message) bool { @@ -89,29 +91,33 @@ func (m ControlMapping) TriggerIfMatch(msg midi.Message, virtGamepad uinput.Game if m.Is(msg) { var ( valueAbsolute uint8 - valueNormalised float32 + valueNormalised float64 ) msg.GetControlChange(nil, nil, &valueAbsolute) // value is 0-127, normalise - valueNormalised = float32(valueAbsolute) / 127 + valueNormalised = float64(valueAbsolute) / 127 if m.isSigned { valueNormalised *= 2 valueNormalised -= 1 } - log.Debugf("%s: value %v", m.comment, valueNormalised) + if math.Abs(valueNormalised) < m.deadzone { + valueNormalised = 0 + } + + log.Debug(m.comment, "value", valueNormalised, "deadzone", m.deadzone) switch m.axis { case LeftX: - return virtGamepad.LeftStickMoveX(valueNormalised) + return virtGamepad.LeftStickMoveX(float32(valueNormalised)) case LeftY: - return virtGamepad.LeftStickMoveY(valueNormalised) + return virtGamepad.LeftStickMoveY(float32(valueNormalised)) case RightX: - return virtGamepad.RightStickMoveX(valueNormalised) + return virtGamepad.RightStickMoveX(float32(valueNormalised)) case RightY: - return virtGamepad.RightStickMoveY(valueNormalised) + return virtGamepad.RightStickMoveY(float32(valueNormalised)) } } From ce0f466a0e8d491b09c1d97a10ac8fe458cd76d6 Mon Sep 17 00:00:00 2001 From: datalore Date: Sat, 9 Aug 2025 17:23:39 +0200 Subject: [PATCH 7/8] docs: Added godoc comments --- config.go | 15 +++++++++++++++ controller.go | 8 ++++++++ mapping.go | 10 ++++++++++ midi.go | 4 ++++ 4 files changed, 37 insertions(+) diff --git a/config.go b/config.go index 62627ab..26f9d13 100644 --- a/config.go +++ b/config.go @@ -9,10 +9,12 @@ import ( "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"` @@ -20,6 +22,7 @@ type ControllerConfig struct { 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"` @@ -61,6 +64,8 @@ const ( 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 @@ -77,6 +82,8 @@ func ParseConfig(path string) (Config, error) { 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() (ControllerList, error) { var controllerList ControllerList @@ -92,6 +99,9 @@ func (config Config) Construct() (ControllerList, error) { 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() (*Controller, error) { actualController, err := NewController(cc.PortName, cc.VendorID, cc.ProductID) if err != nil { @@ -110,6 +120,7 @@ func (cc ControllerConfig) Construct() (*Controller, error) { return actualController, nil } +// Construct builds the Mapping object. Returns an error if config is invalid. func (mc MappingConfig) Construct() (Mapping, error) { switch mc.Type { case ButtonMappingType: @@ -135,6 +146,8 @@ func (mc MappingConfig) Construct() (Mapping, error) { } } +// 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: @@ -174,6 +187,8 @@ func (bn ButtonName) Construct() (int, error) { } } +// Construct converts an AxisName into the internal representation for a ControllerAxis. +// Returns an error if AxisName is invalid. func (an AxisName) Construct() (ControllerAxis, error) { switch an { case AxisLeftX: diff --git a/controller.go b/controller.go index 508da79..4f974e7 100644 --- a/controller.go +++ b/controller.go @@ -6,14 +6,18 @@ import ( "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 @@ -21,6 +25,8 @@ 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 @@ -55,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{}{} diff --git a/mapping.go b/mapping.go index a5b9ebe..07c1a9e 100644 --- a/mapping.go +++ b/mapping.go @@ -9,12 +9,14 @@ import ( "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 { comment string midiChannel uint8 @@ -22,6 +24,7 @@ type ButtonMapping struct { 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 @@ -33,6 +36,8 @@ func (m ButtonMapping) Is(msg midi.Message) bool { } } +// 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 @@ -55,6 +60,7 @@ func (m ButtonMapping) TriggerIfMatch(msg midi.Message, virtGamepad uinput.Gamep return nil } +// Comment returns the Mappings comment. func (m ButtonMapping) Comment() string { return m.comment } @@ -77,6 +83,7 @@ type ControlMapping struct { 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 @@ -87,6 +94,8 @@ func (m ControlMapping) Is(msg midi.Message) bool { } } +// 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 ( @@ -124,6 +133,7 @@ func (m ControlMapping) TriggerIfMatch(msg midi.Message, virtGamepad uinput.Game return nil } +// Comment returns the Mappings comment. func (m ControlMapping) Comment() string { return m.comment } diff --git a/midi.go b/midi.go index 47ee925..923d74e 100644 --- a/midi.go +++ b/midi.go @@ -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 { From ae28a4d385cd341059e6a07e5a925f90fdb0a0c9 Mon Sep 17 00:00:00 2001 From: datalore Date: Sat, 9 Aug 2025 17:43:55 +0200 Subject: [PATCH 8/8] housekeeping: Refactored API to separate packages --- config.go => config/config.go | 34 ++++++++-------- main.go | 6 ++- controller.go => translation/controller.go | 2 +- mapping.go => translation/mapping.go | 46 +++++++++++----------- midi.go => translation/midi.go | 2 +- 5 files changed, 47 insertions(+), 43 deletions(-) rename config.go => config/config.go (83%) rename controller.go => translation/controller.go (99%) rename mapping.go => translation/mapping.go (78%) rename midi.go => translation/midi.go (98%) diff --git a/config.go b/config/config.go similarity index 83% rename from config.go rename to config/config.go index 26f9d13..ade05ab 100644 --- a/config.go +++ b/config/config.go @@ -1,9 +1,11 @@ -package main +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" @@ -84,8 +86,8 @@ func ParseConfig(path string) (Config, error) { // 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() (ControllerList, error) { - var controllerList ControllerList +func (config Config) Construct() (translation.ControllerList, error) { + var controllerList translation.ControllerList for _, controllerConfig := range config.Controller { actualController, err := controllerConfig.Construct() @@ -102,8 +104,8 @@ func (config Config) Construct() (ControllerList, error) { // 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() (*Controller, error) { - actualController, err := NewController(cc.PortName, cc.VendorID, cc.ProductID) +func (cc ControllerConfig) Construct() (*translation.Controller, error) { + actualController, err := translation.NewController(cc.PortName, cc.VendorID, cc.ProductID) if err != nil { return actualController, err } @@ -121,28 +123,28 @@ func (cc ControllerConfig) Construct() (*Controller, error) { } // Construct builds the Mapping object. Returns an error if config is invalid. -func (mc MappingConfig) Construct() (Mapping, error) { +func (mc MappingConfig) Construct() (translation.Mapping, error) { switch mc.Type { case ButtonMappingType: button, err := mc.Button.Construct() if err != nil { - return ButtonMapping{}, err + return translation.ButtonMapping{}, err } log.Debug("Parsed button mapping", "comment", mc.Comment, "midiChannel", mc.MidiChannel, "midiKey", mc.MidiKey, "button", button) - return ButtonMapping{mc.Comment, mc.MidiChannel, mc.MidiKey, button}, nil + return translation.ButtonMapping{mc.Comment, mc.MidiChannel, mc.MidiKey, button}, nil case ControlMappingType: axis, err := mc.Axis.Construct() if err != nil { - return ControlMapping{}, err + 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 ControlMapping{mc.Comment, mc.MidiChannel, mc.MidiController, axis, mc.IsSigned, mc.Deadzone}, nil + return translation.ControlMapping{mc.Comment, mc.MidiChannel, mc.MidiController, axis, mc.IsSigned, mc.Deadzone}, nil default: - return ButtonMapping{}, fmt.Errorf("Invalid mapping type") + return translation.ButtonMapping{}, fmt.Errorf("Invalid mapping type") } } @@ -189,16 +191,16 @@ func (bn ButtonName) Construct() (int, error) { // Construct converts an AxisName into the internal representation for a ControllerAxis. // Returns an error if AxisName is invalid. -func (an AxisName) Construct() (ControllerAxis, error) { +func (an AxisName) Construct() (translation.ControllerAxis, error) { switch an { case AxisLeftX: - return LeftX, nil + return translation.LeftX, nil case AxisLeftY: - return LeftY, nil + return translation.LeftY, nil case AxisRightX: - return RightX, nil + return translation.RightX, nil case AxisRightY: - return RightY, nil + return translation.RightY, nil default: return -1, fmt.Errorf("Invalid axis name \"%s\"", an) } diff --git a/main.go b/main.go index 4a1c6b9..bc6fd14 100644 --- a/main.go +++ b/main.go @@ -5,6 +5,8 @@ import ( "os" "os/signal" + "git.datalore.sh/datalore/midi-hid/config" + "github.com/charmbracelet/log" "gitlab.com/gomidi/midi/v2" ) @@ -34,8 +36,8 @@ func main() { } log.Info("Starting...") - config := must(ParseConfig(configPath)) - controllerList := must(config.Construct()) + conf := must(config.ParseConfig(configPath)) + controllerList := must(conf.Construct()) defer controllerList.Stop() // wait for SIGINT diff --git a/controller.go b/translation/controller.go similarity index 99% rename from controller.go rename to translation/controller.go index 4f974e7..c33cacb 100644 --- a/controller.go +++ b/translation/controller.go @@ -1,4 +1,4 @@ -package main +package translation import ( "github.com/charmbracelet/log" diff --git a/mapping.go b/translation/mapping.go similarity index 78% rename from mapping.go rename to translation/mapping.go index 07c1a9e..9867695 100644 --- a/mapping.go +++ b/translation/mapping.go @@ -1,4 +1,4 @@ -package main +package translation import ( "fmt" @@ -18,10 +18,10 @@ type Mapping interface { // A ButtonMapping maps a MIDI Note to a gamepad button. type ButtonMapping struct { - comment string - midiChannel uint8 - midiKey uint8 - gamepadKey int + CommentStr string + MidiChannel uint8 + MidiKey uint8 + GamepadKey int } // Is checks if the MIDI message msg triggers this Mapping, without actually triggering it. @@ -30,7 +30,7 @@ func (m ButtonMapping) Is(msg midi.Message) bool { switch { case msg.GetNoteOn(&channel, &key, nil), msg.GetNoteOff(&channel, &key, nil): - return (m.midiChannel == channel && m.midiKey == key) + return (m.MidiChannel == channel && m.MidiKey == key) default: return false } @@ -45,13 +45,13 @@ func (m ButtonMapping) TriggerIfMatch(msg midi.Message, virtGamepad uinput.Gamep switch msg.Type() { case midi.NoteOnMsg: if velocity != 0 { - log.Debug(m.comment, "status", "down") - return virtGamepad.ButtonDown(m.gamepadKey) + 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.comment, "status", "up") - return virtGamepad.ButtonUp(m.gamepadKey) + log.Debug(m.CommentStr, "status", "up") + return virtGamepad.ButtonUp(m.GamepadKey) default: return fmt.Errorf("Invalid message type triggered ButtonMapping") } @@ -62,7 +62,7 @@ func (m ButtonMapping) TriggerIfMatch(msg midi.Message, virtGamepad uinput.Gamep // Comment returns the Mappings comment. func (m ButtonMapping) Comment() string { - return m.comment + return m.CommentStr } type ControllerAxis int @@ -75,12 +75,12 @@ const ( ) type ControlMapping struct { - comment string - midiChannel uint8 - midiController uint8 - axis ControllerAxis - isSigned bool - deadzone float64 + 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. @@ -88,7 +88,7 @@ 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) + return (m.MidiChannel == channel && m.MidiController == controller) } else { return false } @@ -107,18 +107,18 @@ func (m ControlMapping) TriggerIfMatch(msg midi.Message, virtGamepad uinput.Game // value is 0-127, normalise valueNormalised = float64(valueAbsolute) / 127 - if m.isSigned { + if m.IsSigned { valueNormalised *= 2 valueNormalised -= 1 } - if math.Abs(valueNormalised) < m.deadzone { + if math.Abs(valueNormalised) < m.Deadzone { valueNormalised = 0 } - log.Debug(m.comment, "value", valueNormalised, "deadzone", m.deadzone) + log.Debug(m.CommentStr, "value", valueNormalised, "deadzone", m.Deadzone) - switch m.axis { + switch m.Axis { case LeftX: return virtGamepad.LeftStickMoveX(float32(valueNormalised)) case LeftY: @@ -135,5 +135,5 @@ func (m ControlMapping) TriggerIfMatch(msg midi.Message, virtGamepad uinput.Game // Comment returns the Mappings comment. func (m ControlMapping) Comment() string { - return m.comment + return m.CommentStr } diff --git a/midi.go b/translation/midi.go similarity index 98% rename from midi.go rename to translation/midi.go index 923d74e..b06589c 100644 --- a/midi.go +++ b/translation/midi.go @@ -1,4 +1,4 @@ -package main +package translation import ( "gitlab.com/gomidi/midi/v2"