From 675ce7f8a4d8c5aab159be739b2dccb24e882898 Mon Sep 17 00:00:00 2001 From: Alexandre Bourget Date: Mon, 8 Apr 2019 01:32:52 -0400 Subject: [PATCH] Add OSC support. Enables piloting Ardour with the same software. See updated sample config for how to use. --- config.go | 56 +++++++++++++++--- main.go | 7 ++- mapper.go | 139 ++++++++++++++++++++++++++------------------- osc.go | 19 +++++++ sample_config.json | 90 ++++++++++++++++++++++++++--- watch.go | 12 +++- 6 files changed, 249 insertions(+), 74 deletions(-) create mode 100644 osc.go diff --git a/config.go b/config.go index f2c8b21..5c52818 100644 --- a/config.go +++ b/config.go @@ -4,8 +4,12 @@ import ( "encoding/json" "fmt" "io/ioutil" + "net/url" "regexp" + "strconv" "strings" + + "github.com/hypebeast/go-osc/osc" ) var loadedConfiguration = &Config{} @@ -19,6 +23,7 @@ type AppConfig struct { Name string `json:"name"` MatchWindowTitles []string `json:"match_window_titles"` SlowJog *int `json:"slow_jog"` // Time in millisecond to use slow jog + Driver string `json:"driver"` windowTitleRegexps []*regexp.Regexp Bindings map[string]string `json:"bindings"` bindings []*deviceBinding @@ -45,11 +50,17 @@ func (ac *AppConfig) parse() error { } type deviceBinding struct { + rawKey string + rawValue string + // Input heldButtons map[int]bool buttonDown int otherKey string + driver string + oscClient *osc.Client + // Output holdButtons []string pressButton string @@ -58,9 +69,32 @@ type deviceBinding struct { } func (ac *AppConfig) parseBindings() error { + driverProtocol := "xdotool" + var oscClient *osc.Client + + switch { + case ac.Driver == "": + case ac.Driver == "xdotool": + case strings.HasPrefix(ac.Driver, "osc://"): + addr, err := url.Parse(ac.Driver) + if err != nil { + return fmt.Errorf("failed parsing osc:// address: %s", err) + } + hostParts := strings.Split(addr.Host, ":") + if len(hostParts) != 2 { + return fmt.Errorf("please specify a port for the osc:// address") + } + port, _ := strconv.ParseInt(hostParts[1], 10, 32) + + driverProtocol = "osc" + oscClient = osc.NewClient(hostParts[0], int(port)) + default: + return fmt.Errorf(`invalid driver %q, use one of: "xdotool" (default), "osc://address:port"`, ac.Driver) + } + for key, value := range ac.Bindings { - binding, description := bindingAndDescription(value) - newBinding := &deviceBinding{heldButtons: make(map[int]bool), original: binding, description: description} + binding, description := bindingAndDescription(driverProtocol, value) + newBinding := &deviceBinding{heldButtons: make(map[int]bool), rawKey: key, rawValue: value, original: binding, description: description, driver: driverProtocol, oscClient: oscClient} // Input input := strings.Split(key, "+") @@ -79,7 +113,7 @@ func (ac *AppConfig) parseBindings() error { } else { keyID := shuttleKeys[key] if keyID == 0 { - return fmt.Errorf("binding %q, expects a button press, not a shuttle or jog movement") + return fmt.Errorf("binding %q, expects a button press, not a shuttle or jog movement", key) } newBinding.heldButtons[keyID] = true } @@ -102,16 +136,24 @@ func (ac *AppConfig) parseBindings() error { ac.bindings = append(ac.bindings, newBinding) - fmt.Printf("BINDING: %#v\n", newBinding) + if *debugMode { + fmt.Printf("BINDING: %#v\n", newBinding) + } } return nil } -var descriptionRE = regexp.MustCompile(`([^/]*)(\s*// *(.+))?`) +var xdoDescriptionRE = regexp.MustCompile(`([^/]*)(\s*// *(.+))?`) +var oscDescriptionRE = regexp.MustCompile(`([^#]*)(\s*# *(.+))?`) -func bindingAndDescription(input string) (string, string) { - matches := descriptionRE.FindStringSubmatch(input) +func bindingAndDescription(protocol, input string) (string, string) { + re := xdoDescriptionRE + if protocol == "osc" { + re = oscDescriptionRE + } + + matches := re.FindStringSubmatch(input) if matches == nil { return input, "" } diff --git a/main.go b/main.go index 45fbb5d..c5797a3 100644 --- a/main.go +++ b/main.go @@ -10,6 +10,7 @@ import ( ) var configFile = flag.String("config", filepath.Join(os.Getenv("HOME"), ".shuttle-go.json"), "Location to the .shuttle-go.json configuration") +var debugMode = flag.Bool("debug", false, "Show debug messages (like window titles)") var logFile = flag.String("log-file", "", "Log to a file instead of stdout") func main() { @@ -54,9 +55,13 @@ func main() { os.Exit(2) } - fmt.Println("ready") + fmt.Println("Ready") mapper := NewMapper(dev) mapper.watcher = watcher + + // IF there's an `osc` driver specified, launch an OSC listener too: + go listenOSCFeedback() + for { if err := mapper.Process(); err != nil { fmt.Println("Error processing input events (continuing):", err) diff --git a/mapper.go b/mapper.go index cd0a06c..642584f 100644 --- a/mapper.go +++ b/mapper.go @@ -4,10 +4,12 @@ import ( "fmt" "os/exec" "reflect" + "strconv" "strings" "time" evdev "github.com/gvalkov/golang-evdev" + "github.com/hypebeast/go-osc/osc" ) // Mapper receives events from the Shuttle devices, and maps (through @@ -77,9 +79,11 @@ func (m *Mapper) dispatch(evs []evdev.InputEvent) { newShuttleVal := shuttleVal(evs) if m.state.shuttle != newShuttleVal { keyName := fmt.Sprintf("S%d", newShuttleVal) - fmt.Println("SHUTTLE", keyName) + if *debugMode { + fmt.Println("SHUTTLE", keyName) + } if err := m.EmitOther(keyName); err != nil { - fmt.Println("Shuttle movement %q: %s\n", keyName, err) + fmt.Printf("Shuttle movement %q: %s\n", keyName, err) } m.state.shuttle = newShuttleVal } @@ -100,9 +104,11 @@ func (m *Mapper) dispatch(evs []evdev.InputEvent) { m.state.buttonsHeld = heldButtons } - fmt.Println("---") - for _, ev := range evs { - fmt.Printf("TYPE: %d\tCODE: %d\tVALUE: %d\n", ev.Type, ev.Code, ev.Value) + if *debugMode { + fmt.Println("---") + for _, ev := range evs { + fmt.Printf("TYPE: %d\tCODE: %d\tVALUE: %d\n", ev.Type, ev.Code, ev.Value) + } } // TODO: Lock on configuration changes @@ -131,7 +137,9 @@ func (m *Mapper) EmitOther(key string) error { upperKey := strings.ToUpper(key) - fmt.Println("EmitOther:", key) + if *debugMode { + fmt.Println("EmitOther:", key) + } for _, binding := range conf.bindings { if binding.otherKey == upperKey { @@ -148,7 +156,9 @@ func (m *Mapper) EmitKeys(modifiers map[int]bool, keyDown int) error { return fmt.Errorf("No configuration for this Window") } - fmt.Println("Emit Keys", modifiers, reverseShuttleKeys[keyDown]) + if *debugMode { + fmt.Println("Emit Keys", modifiers, reverseShuttleKeys[keyDown]) + } for _, binding := range conf.bindings { if reflect.DeepEqual(binding.heldButtons, modifiers) && binding.buttonDown == keyDown { @@ -161,60 +171,75 @@ func (m *Mapper) EmitKeys(modifiers map[int]bool, keyDown int) error { func (m *Mapper) executeBinding(binding *deviceBinding) error { time.Sleep(25 * time.Millisecond) + switch binding.driver { + case "xdotool", "": + fmt.Println("xdotool key --clearmodifiers", binding.original) + return exec.Command("xdotool", "key", "--clearmodifiers", binding.original).Run() + case "osc": + msgs := parseOSCMessages(binding.original) + if msgs == nil { + fmt.Printf("Failed parsing OSC binding for keys %q. Remember %q should start with an /\n", binding.rawKey, binding.rawValue) + return nil + } + for _, msg := range msgs { + if msg.Address == "/sleep" { + fmt.Println("Sleeping for", msg.Arguments[0].(float64), "seconds") + time.Sleep(time.Duration(msg.Arguments[0].(float64)*1000) * time.Millisecond) + continue + } + fmt.Println("Sending OSC message:", msg) + err := binding.oscClient.Send(msg) + if err != nil { + return err + } + } + return nil + default: + panic("unreachable") + } +} - // cookie := xtest.FakeInputChecked(m.watcher.conn, 2, 0x7b00, 0, m.watcher.lastWindowID, 0, 0, 0x00) - // if err := cookie.Check(); err != nil { - // return nil - // } +func parseOSCMessages(multiInput string) (out []*osc.Message) { + inputs := strings.Split(multiInput, " + ") + for _, input := range inputs { + msg := parseOSCMessage(strings.TrimSpace(input)) + if msg == nil { + return nil + } + out = append(out, msg) + } + return +} - // cookie = xtest.FakeInputChecked(m.watcher.conn, 3, 0x7b00, 0, m.watcher.lastWindowID, 0, 0, 0x00) - // return cookie.Check() +func parseOSCMessage(input string) *osc.Message { + fields := strings.Fields(input) // move to something like `sh` interpretation (or quoted strings) if needed + if len(fields) == 0 { + return nil + } - fmt.Println("xdotool key --clearmodifiers", binding.original) - return exec.Command("xdotool", "key", "--clearmodifiers", binding.original).Run() + if !strings.HasPrefix(fields[0], "/") { + return nil + } - // holdButtons := binding.holdButtons - // pressButton := binding.pressButton - - // fmt.Println("Executing bindings:", holdButtons, pressButton) - - // time.Sleep(10 * time.Millisecond) - - // for _, button := range holdButtons { - // fmt.Println("Key down", button) - // time.Sleep(10 * time.Millisecond) - - // if err := m.virtualKeyboard.KeyDown(keyboardKeysUpper[button]); err != nil { - // return err - // } - // } - - // time.Sleep(10 * time.Millisecond) - - // fmt.Println("Key press", pressButton) - // if err := m.virtualKeyboard.KeyDown(keyboardKeysUpper[pressButton]); err != nil { - // return err - // } - - // time.Sleep(10 * time.Millisecond) - - // if err := m.virtualKeyboard.KeyUp(keyboardKeysUpper[pressButton]); err != nil { - // return err - // } - - // time.Sleep(10 * time.Millisecond) - - // for _, button := range holdButtons { - // fmt.Println("Key up", button) - // time.Sleep(10 * time.Millisecond) - // if err := m.virtualKeyboard.KeyUp(keyboardKeysUpper[button]); err != nil { - // return err - // } - // } - - // time.Sleep(50 * time.Millisecond) - - // return nil + msg := osc.NewMessage(fields[0]) + for _, arg := range fields[1:] { + if val, err := strconv.ParseFloat(arg, 64); err == nil { + msg.Append(val) + } else if val, err := strconv.ParseInt(arg, 10, 64); err == nil { + msg.Append(val) + } else if arg == "true" { + msg.Append(true) + } else if arg == "false" { + msg.Append(false) + } else if arg == "nil" { + msg.Append(nil) + } else if arg == "null" { + msg.Append(nil) + } else { + msg.Append(arg) + } + } + return msg } func jogVal(evs []evdev.InputEvent) int { diff --git a/osc.go b/osc.go new file mode 100644 index 0000000..882cc06 --- /dev/null +++ b/osc.go @@ -0,0 +1,19 @@ +package main + +import ( + "fmt" + + "github.com/hypebeast/go-osc/osc" +) + +func listenOSCFeedback() { + addr := "127.0.0.1:8000" + server := &osc.Server{Addr: addr} + + server.Handle("*", func(msg *osc.Message) { + osc.PrintMessage(msg) + }) + + fmt.Println("Listening on :8000 for incoming OSC feedback") + server.ListenAndServe() +} diff --git a/sample_config.json b/sample_config.json index 3829b53..ea7ab36 100644 --- a/sample_config.json +++ b/sample_config.json @@ -3,20 +3,20 @@ { "name": "Lightworks", "match_window_titles": [ - "^Lightworks$", ".*" + "^Lightworks$" ], "slow_jog": 200, "bindings": { "F1": "Escape // Switch viewer-recorder", - - "F3": "x // Delete", - "M1+F3": "z // Blackout", - "F2": "p // Clear Marks", "M1+F2": "Cyrillic_YA // Swap In-Out Marks", + "F3": "x // Delete", + "M1+F3": "z // Blackout", "F4": "v // Insert", "M1+F4": "b // Replace", + "B2+F4": "f // Clipboard Insert", + "B2+M1+F4": "g // Clipboard Replace", "M1+F5": "h // Home", "F5": "a // Prev Clip", @@ -40,9 +40,9 @@ "M1+M2+F3": "Tab", "M1+M2+F4": "Tab", - "B2+F6": "G // Previous Tile in Bin", + "B2+F5": "G // Previous Tile in Bin", + "B2+F6": "J // Next Tile in Bin", "B2+F7": "H // Load Tile into Viewer", - "B2+F8": "J // Next Tile in Bin", "B4+F5": "hebrew_lamed // Live source 1", "B4+F6": "hebrew_finalmem // Live source 2", @@ -80,6 +80,82 @@ "S6": "B", "S7": "N" } + }, + { + "name": "Ardour", + "match_window_titles": [ + "Ardour$" + ], + "slow_jog": 200, + "driver": "osc://localhost:3819", + "__see_ardour_docs_for_actions": "http://manual.ardour.org/appendix/menu-actions-list/ and http://manual.ardour.org/using-control-surfaces/controlling-ardour-with-osc/osc-control/", + "bindings": { + "F1": "/access_action Transport/record-roll", + "F2": "/transport_stop + /sleep 0.25 + /access_action Editor/playhead-to-previous-region-boundary + /sleep 0.05 + /access_action Common/finish-range + /sleep 0.05 + /jump_seconds -0.1 + /sleep 0.05 + /access_action Common/start-range + /sleep 0.05 + /access_action Editor/editor-cut # Clears Marks on Lightworks", + "M1+F2": "Cyrillic_YA # Swap In-Out Marks", + + "F3": "/access_action Editor/editor-cut + /access_action Editor/playhead-to-previous-region-boundary # Delete", + "M1+F3": "z # Blackout", + "F4": "v # Insert", + "M1+F4": "b # Replace", + "B2+F4": "f # Clipboard Insert", + "B2+M1+F4": "g # Clipboard Replace", + + "M1+F5": "/goto_start # Home", + "F5": "/access_action Editor/playhead-to-previous-region-boundary # Prev Clip", + "F6": "/access_action Editor/playhead-to-next-region-boundary", + "M1+F6": "/goto_end # End", + + "F7": "/access_action Common/start-range # Mark In", + "M1+F7": "/access_action Common/finish-range # Mark Out", + + "F8": "/access_action Common/finish-range # Play Backwards", + "F9": "/transport_play # Play", + + "M2+F1": "q", + "M2+F2": "w", + "M2+F3": "e", + "M2+F4": "r", + "M2+F9": "Tab", + "M1+M2+F1": "Tab", + "M1+M2+F2": "Tab", + "M1+M2+F3": "Tab", + "M1+M2+F4": "Tab", + + "B2+F5": "G # Previous Tile in Bin", + "B2+F6": "J # Next Tile in Bin", + "B2+F7": "H # Load Tile into Viewer", + + "B1+F1": "1 # Toggle V1", + "B1+F2": "2 # Toggle V2", + "B1+F3": "Ctrl+3 # Toggle V3", + "B1+F4": "Ctrl+0 # Toggle All Tracks", + "B1+F5": "3 # Toggle A1", + "B1+F6": "4 # Toggle A2", + "B1+F7": "5 # Toggle A3", + "B1+F8": "6 # Toggle A4", + "B1+F9": "7 # Toggle A5", + + "JogL": "parenleft", + "JogR": "parenright", + "SlowJogL": "comma", + "SlowJogR": "period", + "S-7": "/set_transport_speed -8.0", + "S-6": "/set_transport_speed -4.0", + "S-5": "/set_transport_speed -2.0", + "S-4": "/set_transport_speed -1.0", + "S-3": "/set_transport_speed -0.5", + "S-2": "/set_transport_speed -0.25", + "S-1": "/set_transport_speed -0.1", + "S0": "/transport_stop", + "S1": "/set_transport_speed 0.1", + "S2": "/set_transport_speed 0.25", + "S3": "/set_transport_speed 0.5", + "S4": "/set_transport_speed 1.0", + "S5": "/set_transport_speed 2.0", + "S6": "/set_transport_speed 4.0", + "S7": "/set_transport_speed 8.0" + } } ] } diff --git a/watch.go b/watch.go index e15bcf1..cdde33c 100644 --- a/watch.go +++ b/watch.go @@ -100,12 +100,20 @@ func (w *watcher) loadWindowConfiguration(windowName string) { for _, conf := range loadedConfiguration.Apps { for _, re := range conf.windowTitleRegexps { + if *debugMode { + fmt.Println("Testing title:", windowName) + } if re.MatchString(windowName) { + fmt.Printf("Switching configuration for app %q\n", conf.Name) currentConfiguration = conf - fmt.Printf("Applying configuration for app %q\n", conf.Name) return } } } - currentConfiguration = nil + + if !*debugMode { + currentConfiguration = nil + } else { + fmt.Println("Keeping previous config even if window changed") + } }