├── .gitignore ├── BUGS ├── CHANGELOG ├── LICENSE ├── README.md ├── backend └── backend.go ├── config ├── color.go ├── config.go ├── doc.go └── parameters.go ├── go.mod ├── go.sum ├── help └── help.go ├── macro ├── macro.go └── macro_test.go ├── main.go ├── midi ├── chanmode.go ├── keynum.go ├── midi.go ├── notequeue.go ├── notequeue_test.go └── transform.go ├── op ├── base.go ├── cfilter.go ├── distributor.go ├── doc.go ├── dummy.go ├── expect.go ├── handlers.go ├── input.go ├── monitor.go ├── operator.go ├── output.go ├── player.go ├── registry.go ├── scfilter.go ├── tbase.go ├── transformer.go └── transport.go ├── osc ├── batch.go ├── doc.go ├── osc.go ├── repl.go ├── responder.go └── server.go ├── pigerr └── pigerror.go ├── piglog └── piglog.go ├── pigpath ├── pigpath.go └── pigpath_test.go ├── resources ├── batch │ ├── example │ └── example-2 ├── config.toml ├── help │ ├── ChannelFilter │ ├── Distributor │ ├── MIDIInput │ ├── MIDIOutput │ ├── MIDIPlayer │ ├── Monitor │ ├── OSC │ ├── SingleChannelFilter │ ├── Transformer │ ├── batch │ ├── clear-macros │ ├── config │ ├── connect │ ├── del-all │ ├── del-macro │ ├── del-op │ ├── deselect-all-channels │ ├── deselect-channels │ ├── disconnect-all │ ├── disconnect-child │ ├── disconnect-parents │ ├── enable-midi │ ├── exec │ ├── exit │ ├── help │ ├── info │ ├── invert-channels │ ├── macro │ ├── midi │ ├── new │ ├── op │ ├── ping │ ├── print-config │ ├── print-graph │ ├── q-channel-mode │ ├── q-channel-selected │ ├── q-channels │ ├── q-children │ ├── q-commands │ ├── q-graph │ ├── q-macros │ ├── q-midi-enabled │ ├── q-midi-inputs │ ├── q-midi-outputs │ ├── q-operator-types │ ├── q-operators │ ├── q-parents │ ├── q-roots │ ├── reset-all │ ├── reset-op │ ├── select-all-channels │ └── select-channels └── testFiles │ ├── README │ ├── a1.mid │ ├── a2.mid │ ├── b1.mid │ ├── b2.mid │ ├── b3.mid │ ├── c1.mid │ └── c2.mid ├── smf ├── chunk.go ├── chunk_test.go ├── event.go ├── expect.go ├── expect_test.go ├── header.go ├── header_test.go ├── smf.go ├── smf_test.go ├── take.go ├── take_test.go ├── tempo.go ├── tempo_test.go ├── text.go ├── text_test.go ├── track.go ├── track_test.go ├── vlq.go └── vlq_test.go └── version.go /.gitignore: -------------------------------------------------------------------------------- 1 | # pigiron .gitignore 2 | 3 | 4 | # Ignore temp emacs files 5 | *backup* 6 | *~ 7 | [#]*[#] 8 | .\#* 9 | 10 | 11 | # Hide go files. 12 | *._go 13 | __op_old 14 | 15 | 16 | *.vscode 17 | 18 | // executable & test batch file 19 | pigiron 20 | test.osc -------------------------------------------------------------------------------- /BUGS: -------------------------------------------------------------------------------- 1 | ---------------------------------------------------------------------- 001 2 | BUG 001 21-June-2021 CLOSED 3 | OSC command /pig/q-children and pig/q-parents without arguments causes 4 | a segmentation fault. 5 | 6 | 1) expect() is not detecting missing argument. 7 | 2) In queryChildren() msg.Arguments has a spurious empty strings. 8 | Fix: expect() test for spurious initial value in arguments. 9 | 10 | ---------------------------------------------------------------------- 002 11 | BUG 002 21-June-2021 CLOSED 12 | Configuration color specifications uses illegal escape charter. 13 | 14 | Replaced with named colors. 15 | 16 | ---------------------------------------------------------------------- 003 17 | BUG 003 21-June-2021 CLOSED 18 | Reading batch file from command line causes segmentation fault. 19 | Feature is temporarily disabled. 20 | 21 | CLOSED, changed so that batch file was loaded after REPL go-routine 22 | had started. 23 | 24 | ---------------------------------------------------------------------- 004 25 | BOG 004 12-July-2021 CLOSED 26 | Executing print-forest on the REPL caused program to freeze. 27 | Not sure of the exact configuration at the time. 28 | 29 | Suspect this is actually BUG 006. 30 | 31 | ---------------------------------------------------------------------- 005 32 | BUG 005 22-July-2021 CLOSED 33 | OSC command /pig/op , ...., is being rejected by expect. 34 | The operator name is rejected when it arrives via OSC. 35 | The same command from the REPL is accepted. 36 | 37 | There is an inconsistency in how argument strings are being split 38 | REPL and remote commands. For the command /pig/op foo, ping 39 | The REPL sends 'foo' to expect 40 | The remote command is sending 'foo,' 41 | 42 | FIX: Added explicit string trimming function to Expect values. 43 | 44 | ---------------------------------------------------------------------- 006 45 | BUG 006 23-July-2021 CLOSED (hard to reproduce) 46 | Program froze after entering 'op p, load, !/resources/testFiles/a2.mid' 47 | p was name of MIDIPlayer operator and named file was valid MIDI file. 48 | The failing command was entered after a long stream of transport test commands 49 | without a loaded MIDI file. 50 | 51 | Program froze in similar manner after entering 'op p, q-position'. 52 | Again this was after approx half-dozen transport test commands. 53 | This time there was a valid MIDI file loaded and it had just been played. 54 | 55 | Added logging to document command sequences. 56 | 57 | Clarification: 'froze' is not the right description. The REPL continued 58 | to accept input, it just would not process anything, similarly external 59 | OSC messages were ignored. 60 | 61 | This bug has not occurred after switching to gomidi, presumed fixed. 62 | 63 | ---------------------------------------------------------------------- 007 64 | BUG 007 08-Aug-2021 CLOSED 65 | del-all command crashes program. 66 | This appears related to the MIDI poll loop trying to access destroyed 67 | MIDIInputs. 68 | 69 | Made it an error to delete a MIDIInput operator. 70 | The op.ClearRegistry function now skips all MIDIInputs. 71 | 72 | 73 | ---------------------------------------------------------------------- 008 74 | BUG 008 11-Aug-2021 CLOSED 75 | select-all-channels causes SingleChannelMode operators to switch to 76 | channel 16, instead of being ignored. 77 | 78 | Added explicit test for MultiChannel mode before altering channel. 79 | 80 | ---------------------------------------------------------------------- 009 81 | BUG 009 11-Aug-2021 CLOSED Not able to reproduce. 82 | Stuck notes on some legacy synths. 83 | Some legacy synths, specifically a Yamaha TX816 and Yamaha MU100R, are 84 | prone to stuck notes while using Pigiron. More modern instruments do 85 | not seem to be effected. 86 | 87 | It is not verified but this appears to happen when there is a high 88 | density of MIDI traffic. 89 | 90 | ---------------------------------------------------------------------- 010 91 | BUG 010 14-Aug-2021 CLOSED 92 | External OSC commands not added to log file. 93 | 94 | ---------------------------------------------------------------------- 011 95 | BUG 011 15-Aug-2021 CLOSED 96 | MIDPlayer seg-fault when loading MIDI file including System Exclusive data. 97 | 98 | Bug is probably in convertTrackBytes function in midi/track.go at line 159. 99 | Handling sysex events has not been implemented. 100 | 101 | Added explicit SysEx clause in convertTrackBytes. 102 | 103 | ---------------------------------------------------------------------- 012 104 | BUG 012 16-Aug-2021 CLOSED (hard to reproduce) 105 | Executing MIDIPlayer stop sometimes causes segmentation violation. 106 | See stack trace in Pigiron/CrashLogs/BUG012/ 107 | 108 | This bug has not occurred after switching to gomidi, presumed fixed. 109 | 110 | ---------------------------------------------------------------------- 013 111 | BUG 013 16-Aug-2021 CLOSED 112 | Panics on spurious input while running in Konsol. 113 | 114 | While running Pigiron in Konsol terminal emulator, hitting the up-arrow 115 | key can produce text which appears as invalid regex: ^[[A Pigiron panics 116 | if it attempts to read this text panic: regexp: Compile("/pig/\x1b[A"): 117 | error parsing regexp: missing closing ]: `[A` 118 | 119 | Added input character filter. 120 | 121 | ---------------------------------------------------------------------- 014 122 | BUG 014 18-Aug-2021 CLOSED 123 | ChannelFilter incorrectly decrements MIDI channel nibble. 124 | Removed subtraction operation. 125 | 126 | 127 | ---------------------------------------------------------------------- 015 128 | BUG 015 18-Aug-2021 CLOSED 129 | Monitor log file timestamps are incorrect. 130 | 131 | ---------------------------------------------------------------------- 016 132 | BUG 016 24-Aug-2021 CLOSED 133 | 134 | MIDIPlayer does not recognize tempo changes. 135 | 136 | ---------------------------------------------------------------------- 017 137 | BUG 017 07-Jan-2022 OPEN 138 | MIDIPlayer timing not particularly good. 139 | 140 | Also when starting playback the first several events are often rushed. 141 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | 2 | Pigiron CHANGELOG 3 | 4 | 0.1.0 beta 5 | Initial release 6 | 7 | 0.1.01 beta 19-Aug-2021 8 | Fixes typos 9 | Fixes BUG 010, external OSC commands now added to log file. 10 | Fixes BUG 014, ChannelFilter no longer decrements MIDI channel. 11 | Adds exec OSC command. 12 | Removes MIDIOutput note-off velocity disable. 13 | Adds optional logging to Monitor operator. 14 | 15 | 0.1.02 beta 16 | Fixes BUG 008, select-all-channels no longer effects SingleChannel operators. 17 | Closed BUG 009, stuck notes on legacy Yamaha synths, unable to reproduce. 18 | Fixes BUG 011, MIDIPlayer no longer crashes when loading Sysex. 19 | Fixes BUG 013, REPL no longer panics on out of bounds characters. 20 | Fixes BUG 015, Monitor timestamp. 21 | Fixes BUG, remoteSetValue was returning nil for some error conditions. 22 | Adds example-2 batch file, using Transposer to generate chords. 23 | 24 | 0.1.03 beta 25 | Adds REPL command validation. 26 | Fixes BUG 016 MIDIPlayer now recognizes tempo. 27 | Removes Delay Operator. 28 | 29 | 0.2.00 beta 30 | Replaces portmidi backend with gomidi. 31 | Renamed Transposer operator to Transformer. 32 | Complete rewrite of MIDIPlayer and SMF. 33 | Rewrites Monitor output function as go routine. 34 | Closes BUGS 006 and 012. 35 | Adds help topic filter. 36 | Adds filter term to q-commands. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Steven Jones 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pigiron README 2 | 3 | (c) 2021 Steven Jones 4 | 5 | **Pigiron** is a fully configurable MIDI routing utility written in Go. It 6 | includes a MIDI file player and has a comprehensive OSC interface. 7 | 8 | The primary Pigiron object is called an **Operator**. Each Operator has 9 | zero or more MIDI inputs and zero or more MIDI outputs. When an Operator 10 | receives a MIDI message, it determines if the message should be forwarded 11 | to its outputs. An Operator may also modify the message prior to 12 | re-sending it. 13 | 14 | Version 0.2.0 replaces the original portmidi backend with gomidi. After 15 | approximately 6 weeks of practical use, it appears to be more stable. There 16 | are still timing issues with the MIDIPlayer. It is OK for auditioning MIDI 17 | files, but not quite there for production use. 18 | 19 | 20 | ## Operator Types 21 | 22 | The following Operators are currently available: 23 | 24 | 25 | - ChannelFilter - filter events by MIDI channel. 26 | - SingleChannelFilter - More efficient channel filter fir single channel filtering. 27 | - Distributor - transmit events over several MIDI channels. 28 | - MIDIInput - wrapper for MIDI input device. 29 | - MIDIOutput - wrapper for MIDI output device. 30 | - MIDIPlayer - MIDI file player. 31 | - Monitor - print incoming MIDI messages. 32 | - Transformer - manipulate MIDI data bytes. 33 | 34 | 35 | There are three distinct ways to interact with Pigiron. 36 | 37 | 1. Remotely via OSC messages. 38 | 2. Manually enter commands at terminal prompt. 39 | 3. Load a batch file of commands. 40 | 41 | 42 | The command syntax for these modes are nearly identical. The only 43 | difference is that the command 'foo' at the terminal prompt or in a batch 44 | file is entered directly, while as an OSC message it is prefixed with the 45 | application OSC id (by default /pig/). 46 | 47 | foo command at terminal or in batch file. 48 | /pig/foo as OSC message. 49 | 50 | 51 | ## Getting help 52 | 53 | On startup Pigiron displays a command prompt (by default /pig: ). For a 54 | list of help topics enter 55 | 56 | **/pig: help topics** 57 | 58 | For a list of commands enter 59 | 60 | **/pig: q-commands** 61 | 62 | 63 | For details on how OSC messages are handled, enter 64 | 65 | **/pig: help OSC** 66 | 67 | In general commands which begin with 'q-' (for query) returns some 68 | information. 69 | 70 | The resources/batch directory contains the batch file 'example' 71 | which illustrates several commands to set up a basic MIDI process. 72 | It is heavily annotated. 73 | 74 | 75 | ## Dependencies 76 | 77 | Pigiron has been compiled and tested on Linux. For compilation on 78 | Ubuntu 22.04 the following packages were required: 79 | 80 | - build-essential 81 | - librtmidi-dev 82 | 83 | Golang dependencies are: 84 | 85 | - go 1.16 86 | - github.com/pelletier/go-toml 87 | - github.com/rakyll/portmidi 88 | - github.com/hypebeast/go-osc 89 | 90 | 91 | 92 | ## Installation 93 | 94 | **Build Pigiron** 95 | 96 | 97 | In a terminal navigate into the Pigiron directory and enter 98 | 99 | [pigiron]$ go build . 100 | 101 | 102 | **Install** 103 | 104 | Copy the generated pigiron executable to a location included on $HOME/$PATH, typical 105 | locations would be ~/.local/bin or ~.bin 106 | 107 | 108 | **Configuration Directory** 109 | 110 | - Linux : ~/.config/pigiron/ 111 | - Windows : To be determined. 112 | - OSX : To be determined. 113 | 114 | The structure within the .config/pigiron/ directory is: 115 | 116 | ~/.config/ 117 | | 118 | +--pigiron/ 119 | | 120 | +-- config.toml 121 | +-- log 122 | | 123 | +-- batch/ 124 | | 125 | +-- resources/ 126 | | 127 | +-- help/ 128 | +-- testFiles/ 129 | 130 | 131 | 132 | Within .config/pigiron/ create the batch directory and either simlink or 133 | copy the resources directory from the Pigiron project. Pigiron can 134 | operate without the resources directory but help will not be available and 135 | some unit test will fail. 136 | 137 | 138 | 139 | ## Command Line Options 140 | 141 | Pigiron has the following command line options: 142 | 143 | --config filename # Use alternate configuration file. 144 | --batch filename # Load named batch file. 145 | 146 | In general filenames within Pigiron may be prefixed with one of two special 147 | characters. 148 | 149 | '~/foo' names the file foo relative to the user's home directory. 150 | '!/foo' names a file relative to the configuration directory. 151 | 152 | ## GUI? 153 | 154 | Pigiron is strictly a terminal based, however due to it's OSC 155 | interface it should be relatively easy to write a GUI client app. 156 | 157 | -------------------------------------------------------------------------------- /backend/backend.go: -------------------------------------------------------------------------------- 1 | package backend 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "strconv" 7 | _ "gitlab.com/gomidi/midi/v2/drivers/rtmididrv" // auto registers driver 8 | gomidi "gitlab.com/gomidi/midi/v2" 9 | ) 10 | 11 | 12 | func InputNames() []string { 13 | acc := make([]string,0, 8) 14 | ins, _ := gomidi.Ins() 15 | for _, in := range ins { 16 | acc = append(acc, in.String()) 17 | } 18 | return acc 19 | } 20 | 21 | 22 | func OutputNames() []string { 23 | acc := make([]string,0, 8) 24 | outs, _ := gomidi.Outs() 25 | for _, out := range outs { 26 | acc = append(acc, out.String()) 27 | } 28 | return acc 29 | } 30 | 31 | func getOutputByIndex(s string) (gomidi.Out, error) { 32 | var err error 33 | var index int 34 | outs, _ := gomidi.Outs() 35 | index, err = strconv.Atoi(s) 36 | if err != nil { 37 | return nil, err 38 | } 39 | if index < 0 || len(outs) <= index { 40 | err = fmt.Errorf("MIDI output index out of bounds: %d", index) 41 | return nil, err 42 | } 43 | return outs[index], nil 44 | } 45 | 46 | func getOutputByName(pattern string) (gomidi.Out, error) { 47 | var err error 48 | outs, _ := gomidi.Outs() 49 | for _, port := range outs { 50 | name := port.String() 51 | if strings.Contains(name, pattern) { 52 | return port, nil 53 | } 54 | } 55 | err = fmt.Errorf("Pattern '%s' did not match any MIDI device", pattern) 56 | return nil, err 57 | } 58 | 59 | 60 | // GetMIDIOuput returns indicated MIDI output. 61 | // pattern may be either the output's index (as string) 62 | // or a sub-string of the port's name. 63 | // 64 | func GetMIDIOutput(pattern string) (gomidi.Out, error) { 65 | var err error 66 | var out gomidi.Out 67 | out, err = getOutputByIndex(fmt.Sprintf("%s", pattern)) 68 | if err != nil { 69 | out, err = getOutputByName(pattern) 70 | } 71 | return out, err 72 | } 73 | 74 | 75 | func getInputByIndex(s string) (gomidi.In, error) { 76 | var err error 77 | var index int 78 | ins, _ := gomidi.Ins() 79 | index, err = strconv.Atoi(s) 80 | if err != nil { 81 | return nil, err 82 | } 83 | if index < 0 || len(ins) <= index { 84 | err = fmt.Errorf("MIDI input index in of bounds: %d", index) 85 | return nil, err 86 | } 87 | return ins[index], nil 88 | } 89 | 90 | 91 | func getInputByName(pattern string) (gomidi.In, error) { 92 | var err error 93 | ins, _ := gomidi.Ins() 94 | for _, port := range ins { 95 | name := port.String() 96 | if strings.Contains(name, pattern) { 97 | return port, nil 98 | } 99 | } 100 | err = fmt.Errorf("Pattern '%s' did not match any MIDI device", pattern) 101 | return nil, err 102 | } 103 | 104 | 105 | // GetMIDIInput returns selected MIDI input port. 106 | // pattern may be either the output's index (as string) 107 | // or a sub-string of the port's name. 108 | // 109 | func GetMIDIInput(pattern string) (gomidi.In, error) { 110 | var err error 111 | var in gomidi.In 112 | in, err = getInputByIndex(fmt.Sprintf("%s", pattern)) 113 | if err != nil { 114 | in, err = getInputByName(pattern) 115 | } 116 | return in, err 117 | } 118 | 119 | -------------------------------------------------------------------------------- /config/color.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import "runtime" 4 | 5 | /* 6 | * Defines terminal colors. 7 | * 8 | */ 9 | 10 | var colorMap map[string]string = make(map[string]string) 11 | 12 | func defineColors() { 13 | colorMap["red"] = "\033[31m" 14 | colorMap["green"] = "\033[32m" 15 | colorMap["yellow"] = "\033[33m" 16 | colorMap["blue"] = "\033[34m" 17 | colorMap["purple"] = "\033[35m" 18 | colorMap["cyan"] = "\033[36m" 19 | colorMap["gray"] = "\033[37m" 20 | colorMap["white"] = "\033[97m" 21 | } 22 | 23 | 24 | // getColor returns terminal color sequence for named color. 25 | // 26 | // Terminal colors not supported for Windows. Always returns empty-string 27 | // on Windows. 28 | // 29 | // Returns empty string for invalid color name. 30 | // 31 | func getColor(colorName string) string { 32 | if runtime.GOOS == "winbdows" { 33 | return "" 34 | } else { 35 | code, flag := colorMap[colorName] 36 | if flag == false { 37 | code = "" 38 | } 39 | return code 40 | } 41 | } 42 | 43 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | /* 4 | * Parse command line arguments. 5 | * Load toml config file. 6 | * Establish initial OSC batch filename. 7 | */ 8 | 9 | import ( 10 | "flag" 11 | "fmt" 12 | "os" 13 | "path/filepath" 14 | "strconv" 15 | "strings" 16 | toml "github.com/pelletier/go-toml" 17 | "github.com/plewto/pigiron/pigpath" 18 | ) 19 | 20 | var ( 21 | GlobalParameters = globalParameters{} 22 | configFilename string 23 | BatchFilename string 24 | tomlTree *toml.Tree 25 | ) 26 | 27 | func ConfigFilename() string { 28 | return configFilename 29 | } 30 | 31 | // parseCommandLine deciphers command line arguments. 32 | // 33 | // --config filename 34 | // Use alternate configuration file. 35 | // Defaults to ~//pigiron/config.toml 36 | // 37 | // --batch filename 38 | // Sets OSC batch file to run at startup. 39 | // Defaults to no file. 40 | // 41 | func parseCommandLine() { 42 | configDir, err := os.UserConfigDir() 43 | if err != nil { 44 | configDir = ".config" 45 | } 46 | // config filename 47 | defaultFile := filepath.Join(configDir, "pigiron", "config.toml") 48 | flag.StringVar(&configFilename, "config", defaultFile, "Sets configuration file.") 49 | // batch filename 50 | defaultFile = "" 51 | flag.StringVar(&BatchFilename, "batch", defaultFile, "Sets initial OSC batch file.") 52 | BatchFilename = pigpath.SubSpecialDirectories(BatchFilename) 53 | 54 | flag.Parse() 55 | } 56 | 57 | 58 | // hasPath returns true if the config file contains TOML path. 59 | // 60 | func hasPath(path string) bool { 61 | return tomlTree.HasPath(strings.Split(path, ".")) 62 | } 63 | 64 | 65 | // readInt reads an int from the config file. 66 | // Returns fallback if path does not exists or its value is invalid. 67 | // 68 | func readInt(path string, fallback int64) int64 { 69 | if hasPath(path) { 70 | raw := fmt.Sprintf("%v", tomlTree.Get(path)) 71 | value, err := strconv.Atoi(raw) 72 | if err != nil { 73 | msg := "ERROR: Config '%s' expected int, found '%s'. Using default %v\n" 74 | fmt.Printf(msg, path, raw, fallback) 75 | return fallback 76 | } else { 77 | return int64(value) 78 | } 79 | } else { 80 | msg :="ERROR: Config path '%s' missing, using default %v\n" 81 | fmt.Printf(msg, path, fallback) 82 | return fallback 83 | } 84 | } 85 | 86 | 87 | // readFloat reads float from config file. 88 | // Returns fallback if path does not exists or its value is invalid. 89 | // 90 | func readFloat(path string, fallback float64) float64 { 91 | if hasPath(path) { 92 | raw := fmt.Sprintf("%v", tomlTree.Get(path)) 93 | value, err := strconv.ParseFloat(raw, 64) 94 | if err != nil { 95 | msg := "ERROR: Config '%s' expected float, found '%s'. Using default %v\n" 96 | fmt.Printf(msg, path, raw, fallback) 97 | return fallback 98 | } else { 99 | return value 100 | } 101 | } else { 102 | msg :="ERROR: Config path '%s' missing, using default %v\n" 103 | fmt.Printf(msg, path, fallback) 104 | return fallback 105 | } 106 | } 107 | 108 | // readString reads a string value from config file. 109 | // Returns fallback if the path dose not exists. 110 | // 111 | func readString(path string, fallback string) string { 112 | if hasPath(path) { 113 | return fmt.Sprintf("%v", tomlTree.Get(path)) 114 | } else { 115 | msg :="ERROR: Config path '%s' missing, using default %v\n" 116 | fmt.Printf(msg, path, fallback) 117 | return fallback 118 | } 119 | } 120 | 121 | // readBool reads Boolean value from config file. 122 | // Returns fallback if the path does not exists or is invalid. 123 | // 124 | func readBool(path string, fallback bool) bool { 125 | if hasPath(path) { 126 | flag, err := strconv.ParseBool(fmt.Sprintf("%s", tomlTree.Get(path))) 127 | if err != nil { 128 | flag = fallback 129 | } 130 | return flag 131 | } else { 132 | return fallback 133 | } 134 | } 135 | 136 | 137 | // readConfigurationFile sets GlobalParameters fields from toml config file. 138 | // 139 | func readConfigurationFile(filename string) { 140 | var err error 141 | filename = pigpath.SubSpecialDirectories(filename) 142 | tomlTree, err = toml.LoadFile(filename) 143 | if err != nil { 144 | fmt.Printf("ERROR: Can not open configuration file: '%s'\n", filename) 145 | fmt.Println("ERROR: ", err.Error()) 146 | fmt.Println() 147 | ResetGlobalParameters() 148 | return 149 | } else { 150 | GlobalParameters.EnableLogging = readBool("log.enable", true) 151 | GlobalParameters.Logfile = pigpath.SubSpecialDirectories(readString("log.logfile", "!/log")) 152 | GlobalParameters.BatchDirectory = pigpath.SubSpecialDirectories(readString("batch.directory", "!/batch")) 153 | GlobalParameters.OSCServerRoot = readString("osc-server.root", "pig") 154 | GlobalParameters.OSCServerHost = readString("osc-server.host", "127.0.0.1") 155 | GlobalParameters.OSCServerPort = readInt("osc-server.port", 8020) 156 | GlobalParameters.OSCClientRoot = readString("osc-client.root", "pig-client") 157 | GlobalParameters.OSCClientHost = readString("osc-client.host", "127.0.0.1") 158 | GlobalParameters.OSCClientPort = readInt("osc-client.port", 8021) 159 | GlobalParameters.OSCClientFilename = readString("osc-client.file", "") 160 | GlobalParameters.MaxTreeDepth = readInt("tree.max-depth", 12) 161 | GlobalParameters.MIDIInputBufferSize = readInt("midi-input.buffer-size", 1024) 162 | GlobalParameters.MIDIInputPollInterval = readInt("midi-input.poll-interval", 0) 163 | GlobalParameters.MIDIOutputBufferSize = readInt("midi-output.buffer-size", 1024) 164 | GlobalParameters.MIDIOutputLatency = readInt("midi-output.latency", 0) 165 | GlobalParameters.BannerColor = getColor(readString("colors.banner", "")) 166 | GlobalParameters.TextColor = getColor(readString("colors.text", "")) 167 | GlobalParameters.ErrorColor = getColor(readString("colors.error", "")) 168 | } 169 | } 170 | 171 | func init() { 172 | defineColors() 173 | parseCommandLine() 174 | ResetGlobalParameters() 175 | readConfigurationFile(configFilename) 176 | } 177 | -------------------------------------------------------------------------------- /config/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | ** config package establishes global parameters on pigiron start. 3 | ** 4 | ** 1) Global parameters are first set to default values. 5 | ** 2) The command line is then checked to see if an alternate config file has 6 | ** been specified. 7 | ** 3) The configuration file, in toml format, is read to set global 8 | ** values. 9 | ** 10 | ** Config file structure and corresponding global parameters 11 | ** 12 | ** [log] 13 | ** enable = bool GlobalParameters.EnableLogging 14 | ** logfile = filename GlobalParameters.Logfile 15 | ** 16 | ** [batch] 17 | ** directory Location for batch files. 18 | ** 19 | ** Enables logging and specifies location of the log file. 20 | ** 21 | ** [osc-server] 22 | ** root = string GlobalParameters.OSCServerRoot 23 | ** host = ip-address GlobalParameters.OSCServerHost 24 | ** port = int GlobalParameters.OSCServerPort 25 | ** 26 | ** [osc-client] 27 | ** root = string GlobalParameters.OSCClientRoot 28 | ** host = ip-address GlobalParameters.OSCClientHost 29 | ** port = int GlobalParameters.OSCClientPort 30 | ** file = filename GlobalParameters.OSCClientFilename 31 | ** 32 | ** Two sets of OSC values are defined: 33 | ** 1) The 'server' represents this instance of pigiron. 34 | ** 2) The 'client' sends OSC messages to pigiron. For each received 35 | ** message pigiron sends a response message back to the client. The 36 | ** response is optionally sent to a specified client file. 37 | ** 38 | ** [tree] 39 | ** max-depth = int GlobalParameters.MaxTreeDepth 40 | ** 41 | ** Sets the maximum depth of the MIDI process tree. No path through 42 | ** the tree, from root to final output, is allowed to pass through more 43 | ** then max-depth operators. 44 | ** 45 | ** [midi-input] 46 | ** buffer-size = int GlobalParameters.MIDIInputBufferSize 47 | ** poll-interval = int (msec) GlobalParameters.MIDIInputPollInterval 48 | ** 49 | ** [midi-output] 50 | ** buffer-size = int GlobalParameters.MIDIOutputBufferSize 51 | ** latency = int GlobalParameters.MIDIOutputLatency 52 | ** 53 | ** buffer-size and latency are parameters passed to the portmidi library. 54 | ** poll-interval is the time, in milliseconds, between polling the MIDI 55 | ** inputs for incoming messages. A value of 0 checks input as fast as 56 | ** possible. 57 | ** 58 | ** [color] 59 | ** banner = color-name GlobalParameters.BannerColor 60 | ** text = color-name GlobalParameters.TextColor 61 | ** error = color-name GlobalParameters.ErrorColor 62 | ** 63 | ** The following named colors are defined: 64 | ** red, green, yellow, blue, purple, cyan, gray and white. 65 | ** Colors are not supported on Windows. 66 | ** 67 | */ 68 | 69 | package config 70 | -------------------------------------------------------------------------------- /config/parameters.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import "fmt" 4 | 5 | 6 | /* 7 | * Defines global configuration parameters. 8 | */ 9 | 10 | // globalParameters struct holds application wide configuration values. 11 | // 12 | type globalParameters struct { 13 | EnableLogging bool 14 | Logfile string 15 | BatchDirectory string 16 | OSCServerRoot string 17 | OSCServerHost string 18 | OSCServerPort int64 19 | OSCClientRoot string 20 | OSCClientHost string 21 | OSCClientPort int64 22 | OSCClientFilename string 23 | MaxTreeDepth int64 24 | MIDIInputBufferSize int64 25 | MIDIInputPollInterval int64 // ms 26 | MIDIOutputBufferSize int64 27 | MIDIOutputLatency int64 28 | BannerColor string 29 | TextColor string 30 | ErrorColor string 31 | } 32 | 33 | // ResetGlobalParameters sets all global configuration parameter to default values." 34 | // 35 | func ResetGlobalParameters() { 36 | GlobalParameters.EnableLogging = false 37 | GlobalParameters.Logfile = "!/log" 38 | GlobalParameters.BatchDirectory = "!/batch" 39 | GlobalParameters.OSCServerRoot = "pig" 40 | GlobalParameters.OSCServerHost = "127.0.0.1" 41 | GlobalParameters.OSCServerPort = 8020 42 | GlobalParameters.OSCClientRoot = "pig-client" 43 | GlobalParameters.OSCClientHost = "127.0.0.1" 44 | GlobalParameters.OSCClientPort = 8021 45 | GlobalParameters.OSCClientFilename = "" 46 | GlobalParameters.MaxTreeDepth = 12 47 | GlobalParameters.MIDIInputBufferSize = 1024 48 | GlobalParameters.MIDIInputPollInterval = 0 49 | GlobalParameters.MIDIOutputBufferSize = 1024 50 | GlobalParameters.MIDIOutputLatency = 0 51 | GlobalParameters.BannerColor = getColor("white") 52 | GlobalParameters.TextColor = getColor("white") 53 | GlobalParameters.ErrorColor = getColor("red") 54 | } 55 | 56 | 57 | // PrintConfig prints the global configuration values. 58 | // 59 | func PrintConfig() { 60 | fmt.Println(ConfigInfo()) 61 | } 62 | 63 | // ConfigInfo returns string representation of global configuration values. 64 | // 65 | func ConfigInfo() string { 66 | acc := "Global configuration values\n" 67 | acc += fmt.Sprintf("\tconfig file was \"%s\"\n", configFilename) 68 | acc += fmt.Sprintf("\tEnableLogging : %v\n", GlobalParameters.EnableLogging) 69 | acc += fmt.Sprintf("\tLogfile : %v\n", GlobalParameters.Logfile) 70 | acc += fmt.Sprintf("\tBatchDirectory : %v\n", GlobalParameters.BatchDirectory) 71 | acc += fmt.Sprintf("\tOSCServerRoot : %v\n", GlobalParameters.OSCServerRoot) 72 | acc += fmt.Sprintf("\tOSCServerHost : %v\n", GlobalParameters.OSCServerHost) 73 | acc += fmt.Sprintf("\tOSCServerPort : %v\n", GlobalParameters.OSCServerPort) 74 | acc += fmt.Sprintf("\tOSCClientRoot : %v\n", GlobalParameters.OSCClientRoot) 75 | acc += fmt.Sprintf("\tOSCClientHost : %v\n", GlobalParameters.OSCClientHost) 76 | acc += fmt.Sprintf("\tOSCClientPort : %v\n", GlobalParameters.OSCClientPort) 77 | acc += fmt.Sprintf("\tOSCClientFilename : %v\n", GlobalParameters.OSCClientFilename) 78 | acc += fmt.Sprintf("\tMaxTreeDepth : %v\n", GlobalParameters.MaxTreeDepth) 79 | acc += fmt.Sprintf("\tMIDIInputBufferSize : %v\n", GlobalParameters.MIDIInputBufferSize) 80 | acc += fmt.Sprintf("\tMIDIInputPollInterval : %v\n", GlobalParameters.MIDIInputPollInterval) 81 | acc += fmt.Sprintf("\tMIDIOutputBufferSize : %v\n", GlobalParameters.MIDIOutputBufferSize) 82 | acc += fmt.Sprintf("\tMIDIOutputLatency : %v\n", GlobalParameters.MIDIOutputLatency) 83 | return acc 84 | } 85 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/plewto/pigiron 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/hypebeast/go-osc v0.0.0-20210408213458-3287e1838f40 7 | github.com/pelletier/go-toml v1.9.2 8 | gitlab.com/gomidi/midi/v2 v2.0.0-alpha.14 9 | gitlab.com/gomidi/midi/v2/drivers/rtmididrv v0.0.0-20210712093228-8dc29768c837 10 | ) 11 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/hypebeast/go-osc v0.0.0-20210408213458-3287e1838f40 h1:w+HdpjIfD+5Kb8aJ1KK9ROuqS9No/p0mSYTx/0XCASc= 2 | github.com/hypebeast/go-osc v0.0.0-20210408213458-3287e1838f40/go.mod h1:lqMjoCs0y0GoRRujSPZRBaGb4c5ER6TfkFKSClxkMbY= 3 | github.com/pelletier/go-toml v1.9.2 h1:7NiByeVF4jKSG1lDF3X8LTIkq2/bu+1uYbIm1eS5tzk= 4 | github.com/pelletier/go-toml v1.9.2/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= 5 | gitlab.com/gomidi/midi/v2 v2.0.0-alpha.8/go.mod h1:quTyMKSQ4Klevxu6gY4gy2USbeZra0fV5SalndmPfsY= 6 | gitlab.com/gomidi/midi/v2 v2.0.0-alpha.14 h1:V54nw/DM3CRgb59BavJy+4cdsbyozRsFH2K/yrowfh4= 7 | gitlab.com/gomidi/midi/v2 v2.0.0-alpha.14/go.mod h1:quTyMKSQ4Klevxu6gY4gy2USbeZra0fV5SalndmPfsY= 8 | gitlab.com/gomidi/midi/v2/drivers/rtmididrv v0.0.0-20210712093228-8dc29768c837 h1:9ZEAThbWKp/TtVI4fp4RYyDk0v/w1Nd0keJyZdSFG6w= 9 | gitlab.com/gomidi/midi/v2/drivers/rtmididrv v0.0.0-20210712093228-8dc29768c837/go.mod h1:ShVSF28zo/g9C4wzSZJRIHwKZOsSMV7ZuxF/yBu6NPM= 10 | gitlab.com/gomidi/midi/v2/drivers/rtmididrv/imported/rtmidi v0.0.0-20210425073027-dcb5d7eb9e83 h1:FuKUpVHyDsZy9y+ZrWrT9uR2/HWY16MOj0/wY++IEY8= 11 | gitlab.com/gomidi/midi/v2/drivers/rtmididrv/imported/rtmidi v0.0.0-20210425073027-dcb5d7eb9e83/go.mod h1:OPj7tH2bUPFpPN1uU6E7ShXuDlJO0+RHlwp32KhcrtE= 12 | -------------------------------------------------------------------------------- /help/help.go: -------------------------------------------------------------------------------- 1 | package help 2 | 3 | // help package provides online help. 4 | // All help documents stored as simple text files in 5 | // 6 | // ~/.config/pigiron/resources/help 7 | // 8 | 9 | import ( 10 | "fmt" 11 | "strings" 12 | "io/ioutil" 13 | "github.com/plewto/pigiron/pigpath" 14 | ) 15 | 16 | 17 | // Help function returns documentation for topic. 18 | // 19 | func Help(topic string) (text string, err error) { 20 | tokens := strings.Split(topic, " ") 21 | if len(tokens) < 1 { 22 | return 23 | } 24 | head := strings.TrimSpace(tokens[0]) 25 | switch head { 26 | case "topics": 27 | filter := "" 28 | if len(tokens) > 1 { 29 | filter = strings.TrimSpace(tokens[1]) 30 | } 31 | return helpTopics(filter) 32 | default: 33 | filename := pigpath.ResourceFilename("help", head) 34 | var data []byte 35 | data, err = ioutil.ReadFile(filename) 36 | text = string(data) 37 | return text, err 38 | } 39 | } 40 | 41 | func helpTopics(filter string) (string, error) { 42 | dirname := pigpath.ResourceFilename("help") 43 | topics, err := ioutil.ReadDir(dirname) 44 | if err != nil { 45 | errmsg := "Can not accesses help directory\n%s" 46 | err = fmt.Errorf(errmsg, err) 47 | return "", err 48 | } 49 | acc := "Help topics:\n" 50 | for _, info := range topics { 51 | name := fmt.Sprintf("%s", info.Name()) 52 | if strings.Contains(name, filter) { 53 | acc += fmt.Sprintf("\t%s\n", name) 54 | } 55 | } 56 | acc += "\n" 57 | return acc, err 58 | } 59 | -------------------------------------------------------------------------------- /macro/macro.go: -------------------------------------------------------------------------------- 1 | /* 2 | ** macro package defines command aliases by text-replacement. 3 | ** Macros may be used interactively at the prompt or within batch files, 4 | ** they do not define new OSC commands. 5 | ** 6 | */ 7 | 8 | package macro 9 | 10 | import ( 11 | "fmt" 12 | "strings" 13 | "strconv" 14 | "sort" 15 | ) 16 | 17 | const DEREF = '$' 18 | 19 | var macros = make(map[string]*Macro) 20 | 21 | 22 | /* 23 | ** Macro struct defines a command replacement. 24 | ** name - The macro's name 25 | ** command - The OSC command name 26 | ** template - arguments to the expanded command. 27 | ** Use $n to substitute nth argument into expanded text. 28 | ** 29 | */ 30 | type Macro struct { 31 | name string 32 | command string 33 | template []string 34 | 35 | } 36 | 37 | func (macro *Macro) String() string { 38 | acc := fmt.Sprintf("Macro %s --> %s ", macro.name, macro.command) 39 | for _, t := range macro.template { 40 | acc += fmt.Sprintf("%s, ", t) 41 | } 42 | acc = strings.TrimSpace(acc) 43 | if len(macro.template) > 0 { 44 | acc = acc[:len(acc)-1] 45 | } 46 | return acc 47 | } 48 | 49 | // Define function creates a new Macro definition. 50 | // name - The macro name. 51 | // command - The OSC command the macro is an alias to. 52 | // template - List of arguments to the expanded command. 53 | // Template contents may either be literal replacement text or have the 54 | // special syntax '$n' which replaces the nth argument to name into the 55 | // expanded command. 56 | // 57 | func Define(name string, command string, template []string) { 58 | m := &Macro{name, command, template} 59 | macros[name] = m 60 | } 61 | 62 | 63 | // Delete function deletes the named macro. 64 | // 65 | func Delete(name string) { 66 | delete(macros, name) 67 | } 68 | 69 | // IsMacro returns true iff macro name exists. 70 | // 71 | func IsMacro(name string) bool { 72 | _, flag := macros[name] 73 | return flag 74 | } 75 | 76 | // Expand function converts macro call to replacement text. 77 | // name - the macro's name. 78 | // args - arguments to name. 79 | // 80 | // Returns expanded macro text. 81 | // 82 | func Expand(name string, args []string) (string, error) { 83 | var err error 84 | var acc string 85 | macro, flag := macros[name] 86 | if !flag { 87 | msg := "Macro '%s' is not defined" 88 | err = fmt.Errorf(msg, name) 89 | return "", err 90 | } 91 | acc = fmt.Sprintf("%s ", macro.command) 92 | for _, t := range macro.template { 93 | t = strings.TrimSpace(t) 94 | if t[0] == DEREF { 95 | var index int 96 | index, err = strconv.Atoi(t[1:]) 97 | if err != nil || index < 0 || index >= len(args) { 98 | msg := "Illegal macro index, '%s', err = %v" 99 | err = fmt.Errorf(msg, t, err) 100 | return "", err 101 | } 102 | acc += fmt.Sprintf("%s, ", strings.TrimSpace(args[index])) 103 | } else { 104 | acc += fmt.Sprintf("%s, ", t) 105 | } 106 | } 107 | acc = strings.TrimSpace(acc) 108 | if len(macro.template) > 0 { // trim final comma 109 | acc = acc[:len(acc)-1] 110 | } 111 | return acc, err 112 | } 113 | 114 | // ListMacros returns sorted list of defined macros. 115 | // 116 | func ListMacros() []string { 117 | keys := make([]string, len(macros)) 118 | acc := make([]string, len(macros)) 119 | index := 0 120 | for key, _ := range macros { 121 | keys[index] = key 122 | index++ 123 | } 124 | sort.Strings(keys) 125 | for i, key := range keys { 126 | acc[i] = macros[key].String() 127 | } 128 | return acc 129 | 130 | } 131 | 132 | // Reset deletes all macros 133 | // 134 | func Reset() { 135 | macros = make(map[string]*Macro) 136 | } 137 | -------------------------------------------------------------------------------- /macro/macro_test.go: -------------------------------------------------------------------------------- 1 | package macro 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | ) 7 | 8 | 9 | func TestMacro(t *testing.T) { 10 | fmt.Print() 11 | Define("Alpha", "Apple", []string{"A", "$0", "$1"}) 12 | if !IsMacro("Alpha") { 13 | msg := "Macro test 1, Expected IsMacro() to return true, got false" 14 | t.Fatalf(msg) 15 | } 16 | if IsMacro("NOT-DEFINED") { 17 | msg := "Macro test 2, Expected IsMacro() to return false, got true" 18 | t.Fatalf(msg) 19 | } 20 | 21 | expect := "Apple A, Bat, Cat" 22 | ex, err := Expand("Alpha", []string{"Bat", "Cat"}) 23 | if err != nil { 24 | msg := "Macro test 3, got unexpected error: %s" 25 | t.Fatalf(msg, err) 26 | } 27 | if ex != expect { 28 | msg := "Macro test 4, Expand returned '%s', expected '%s'" 29 | t.Fatalf(msg, ex, expect) 30 | } 31 | 32 | Delete("Alpha") 33 | if IsMacro("Alpha") { 34 | msg := "Macro test 5, IsMacro returns true after macro was deleted" 35 | t.Fatalf(msg) 36 | } 37 | } 38 | 39 | 40 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "time" 8 | "github.com/plewto/pigiron/config" 9 | "github.com/plewto/pigiron/osc" 10 | "github.com/plewto/pigiron/op" 11 | "github.com/plewto/pigiron/piglog" 12 | _ "github.com/plewto/pigiron/macro" 13 | gomidi "gitlab.com/gomidi/midi/v2" 14 | ) 15 | 16 | 17 | var banner = []string{ 18 | " ____ __________________ ____ _ __ ", 19 | " / __ \\/ _/ ____/ _/ __ \\/ __ \\/ | / / ", 20 | " / /_/ // // / __ / // /_/ / / / / |/ / ", 21 | " / ____// // /_/ // // _, _/ /_/ / /| / ", 22 | "/_/ /___/\\____/___/_/ |_|\\____/_/ |_/ "} 23 | 24 | 25 | 26 | func printBanner() { 27 | fmt.Printf("\n") 28 | fmt.Print(config.GlobalParameters.BannerColor) 29 | for _, line := range banner { 30 | fmt.Println(line) 31 | } 32 | fmt.Print("\n") 33 | cfig, err := os.UserConfigDir() 34 | if err != nil { 35 | fmt.Printf("WARNING: Can not dertermin user's config directory.\n") 36 | fmt.Printf("%s\n", err) 37 | } else { 38 | cfig = filepath.Join(cfig, "pigiron") 39 | fmt.Printf("Configuration directory is '%s'\n", cfig) 40 | } 41 | fmt.Printf("Configuration file: %s\n", config.ConfigFilename()) 42 | if config.GlobalParameters.EnableLogging { 43 | fmt.Printf("Logging to: %s\n", piglog.Logfile()) 44 | } else { 45 | fmt.Printf("Logging disabled\n") 46 | } 47 | fmt.Printf("%s\n", VERSION.String()) 48 | fmt.Print(config.GlobalParameters.TextColor) 49 | fmt.Print("\n\n") 50 | } 51 | 52 | 53 | func main() { 54 | piglog.Log("-------- Pigiron main()") 55 | piglog.Log(VERSION.String()) 56 | printBanner() 57 | piglog.Log(config.ConfigInfo()) 58 | osc.Init() 59 | op.Init() 60 | osc.Listen() 61 | go osc.REPL() 62 | if config.BatchFilename != "" { 63 | err := osc.BatchLoad(config.BatchFilename) 64 | if err != nil { 65 | fmt.Printf("Could not load batch file %s\n", config.BatchFilename) 66 | fmt.Printf("%s\n", err) 67 | } 68 | } 69 | fmt.Println() 70 | // main loop 71 | var pollInterval = time.Duration(config.GlobalParameters.MIDIInputPollInterval) 72 | for { 73 | if osc.Exit { 74 | Exit() 75 | } 76 | time.Sleep(pollInterval * time.Millisecond) 77 | } 78 | } 79 | 80 | func Exit() { 81 | fmt.Println("Pigiron exit.") 82 | Cleanup() 83 | os.Exit(0) 84 | } 85 | 86 | 87 | func Cleanup() { 88 | gomidi.CloseDriver() 89 | op.Cleanup() 90 | osc.Cleanup() 91 | piglog.Close() 92 | } 93 | -------------------------------------------------------------------------------- /midi/keynum.go: -------------------------------------------------------------------------------- 1 | package midi 2 | 3 | /* 4 | ** keynum.go defines string representations for MIDI key number. 5 | ** 6 | */ 7 | 8 | import ( 9 | "fmt" 10 | ) 11 | 12 | var keynames [128]string 13 | 14 | func init() { 15 | base := [12]string { 16 | "C", "C#", "D", "D#", "E", "F", 17 | "F#", "G", "G#", "A", "A#", "B"} 18 | for i:=0; i<128; i++ { 19 | pc := i % 12 20 | oct := i / 12 21 | key := base[pc] + fmt.Sprintf("%d", oct) 22 | keynames[i] = key 23 | key = fmt.Sprintf("%4s", key) 24 | } 25 | 26 | } 27 | 28 | 29 | // KeyName() returns a string representation for a MIDI key number. 30 | // 31 | func KeyName(n byte) string { 32 | if 0 <= n && n < 128 { 33 | return keynames[n] 34 | } else { 35 | return "" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /midi/notequeue.go: -------------------------------------------------------------------------------- 1 | package midi 2 | 3 | /* 4 | ** Defines scheme for resolving open MIDI notes. 5 | ** 6 | */ 7 | 8 | import ( 9 | gomidi "gitlab.com/gomidi/midi/v2" 10 | ) 11 | 12 | 13 | // noteQueueChannel counts unresolved note-on events for a MIDI channel. 14 | // 15 | type noteQueueChannel struct { 16 | openCounts [128]int 17 | } 18 | 19 | func (nqc *noteQueueChannel) reset() { 20 | for i := 0; i < len(nqc.openCounts); i++ { 21 | nqc.openCounts[i] = 0 22 | } 23 | } 24 | 25 | func (nqc *noteQueueChannel) updateCount(msg gomidi.Message) { 26 | switch { 27 | case IsNoteOff(msg): 28 | key := msg.Data[1] 29 | count := nqc.openCounts[key] -1 30 | if count < 0 { 31 | count = 0 32 | } 33 | nqc.openCounts[key] = count 34 | case IsNoteOn(msg): 35 | key := msg.Data[1] 36 | nqc.openCounts[key]++ 37 | default: 38 | // ignore 39 | } 40 | } 41 | 42 | /* 43 | ** NoteQueue struct maintains a count of all unresolved note-on events. 44 | ** 45 | */ 46 | type NoteQueue struct { 47 | channels [16]noteQueueChannel 48 | } 49 | 50 | // nq.Reset() sets all note-counts to 0. 51 | // 52 | func (nq *NoteQueue) Reset() { 53 | for _, c := range nq.channels { 54 | c.reset() 55 | } 56 | } 57 | 58 | 59 | // nq.Update() increments/decrements count for specific note and channel. 60 | // The note count is never negative. 61 | // 62 | func (nq *NoteQueue) Update(msg gomidi.Message) { 63 | d := msg.Data 64 | if len(d) > 0 { 65 | st := d[0] & 0xF0 66 | if st == 0x80 || st == 0x90 { 67 | ci := d[0] & 0x0F 68 | nq.channels[ci].updateCount(msg) 69 | } 70 | } 71 | } 72 | 73 | // nq.OpenCount() returns number of unresolved note-on events for given channel and key. 74 | // The channel parameter has interval 0 <= ci <= 15. 75 | // 76 | func (nq *NoteQueue) OpenCount(ci byte, key byte) int { 77 | if ci < 0 || 16 < ci || key < 0 || 128 <= key { 78 | return 0 79 | } 80 | nqc := nq.channels[ci] 81 | return nqc.openCounts[key] 82 | } 83 | 84 | // nq.OffEvents() returns list of MIDI off events required to resolve all open notes. 85 | // 86 | func (nq *NoteQueue) OffEvents() []gomidi.Message { 87 | var acc = make([]gomidi.Message, 0, 48) 88 | for ci := byte(0); ci < byte(len(nq.channels)); ci++ { 89 | st := byte(0x80 | ci) 90 | for key := byte(0); key < 128; key++ { 91 | for n := 0; n < nq.OpenCount(ci, key); n++ { 92 | off := gomidi.NewMessage([]byte{st, key, 0}) 93 | acc = append(acc, off) 94 | } 95 | } 96 | } 97 | return acc 98 | } 99 | 100 | 101 | // MakeNoteQueue() creates new instance of NoteQueue struct. 102 | // 103 | func MakeNoteQueue() *NoteQueue { 104 | clist := [16]noteQueueChannel{} 105 | for ci, _ := range clist { 106 | clist[ci] = noteQueueChannel{[128]int{}} 107 | } 108 | return &NoteQueue{clist} 109 | } 110 | -------------------------------------------------------------------------------- /midi/notequeue_test.go: -------------------------------------------------------------------------------- 1 | package midi 2 | 3 | import ( 4 | "testing" 5 | "math/rand" 6 | gomidi "gitlab.com/gomidi/midi/v2" 7 | ) 8 | 9 | 10 | 11 | func onEvent(ci byte, key byte) gomidi.Message { 12 | st := byte(0x90 | ci) 13 | return gomidi.NewMessage([]byte{st, key, 64}) 14 | } 15 | 16 | 17 | func offEvent(ci byte, key byte) gomidi.Message { 18 | var st byte 19 | if rand.Intn(100) > 50 { 20 | st = 0x80 | ci 21 | } else { 22 | st = 0x90 | ci 23 | } 24 | return gomidi.NewMessage([]byte{st, key, 0}) 25 | } 26 | 27 | func TestNoteQueue(t *testing.T) { 28 | nq := MakeNoteQueue() 29 | for ci := byte(0); ci < 15; ci++ { 30 | for key := byte(0); key < 8; key++ { 31 | for n := byte(0); n < ci+1; n++ { 32 | nq.Update(onEvent(ci, key)) 33 | } 34 | for n := byte(0); n < ci; n++ { 35 | nq.Update(offEvent(ci, key)) 36 | } 37 | diff := nq.OpenCount(ci, key) 38 | if diff != 1 { 39 | msg := "Expected note count 1, ci = %d, key = %d, got count %d" 40 | t.Fatalf(msg, ci, key, diff) 41 | } 42 | } 43 | } 44 | // check floor value, open count should never be less then 0. 45 | for i := 0; i < 100; i++ { 46 | nq.Update(offEvent(0, 0)) 47 | } 48 | if nq.OpenCount(0, 0) != 0 { 49 | msg := "Negative open note count: %d" 50 | t.Fatalf(msg, nq.OpenCount(0, 0)) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /midi/transform.go: -------------------------------------------------------------------------------- 1 | package midi 2 | 3 | import ( 4 | "fmt" 5 | gomidi "gitlab.com/gomidi/midi/v2" 6 | ) 7 | 8 | 9 | /* 10 | ** Transform interface defines general byte transformation fn(n) -> n' 11 | ** 12 | */ 13 | type Transform interface { 14 | TransformRange() (floor byte, ceiling byte) 15 | Reset() 16 | Value(index byte) (byte, error) 17 | SetValue(index byte, value byte) error 18 | Dump() string 19 | Plot() string 20 | } 21 | 22 | /* 23 | ** DataTable implements Transform using 128-slot lookup table. 24 | ** 25 | */ 26 | type DataTable struct { 27 | table [128]byte 28 | } 29 | 30 | // NewDataTable returns pointer to DataTable 31 | // 32 | func NewDataTable() *DataTable { 33 | dt := DataTable{[128]byte{}} 34 | dt.Reset() 35 | return &dt 36 | } 37 | 38 | // TransformRange() returns table index range 39 | // 40 | func (dt *DataTable) TransformRange()(floor byte, ceiling byte) { 41 | return 0, byte(len(dt.table)) 42 | } 43 | 44 | // Reset() sets table to identity f(x) -> x 45 | // 46 | func (dt *DataTable) Reset() { 47 | f, c := dt.TransformRange() 48 | for i := f; i < c; i++ { 49 | dt.table[i] = byte(i) 50 | } 51 | } 52 | 53 | func (dt *DataTable) validate(n byte, param string) error { 54 | var err error 55 | f, c := dt.TransformRange() 56 | if n < f || c <= n { 57 | msg := "DataTable %s out of bounds: %d" 58 | err = fmt.Errorf(msg, param, n) 59 | } 60 | return err 61 | } 62 | 63 | // Value() returns indexed table value. 64 | // Returns non-nil error if index is out of range. 65 | // 66 | func (dt *DataTable) Value(index byte) (byte, error) { 67 | err := dt.validate(index, "index") 68 | if err != nil { 69 | return 0, err 70 | } 71 | return dt.table[index], err 72 | } 73 | 74 | 75 | // SetValue() sets indexed table value. 76 | // Returns non-nil error if either index or value is out of range. 77 | // 78 | func (dt *DataTable) SetValue(index byte, value byte) error { 79 | err := dt.validate(index, "index") 80 | if err != nil { 81 | return err 82 | } 83 | err = dt.validate(value, "value") 84 | if err != nil { 85 | return err 86 | } 87 | dt.table[index] = value 88 | return err 89 | } 90 | 91 | func (dt *DataTable) Dump() string { 92 | acc := "\tDataTable:" 93 | width := byte(8) 94 | f, c := dt.TransformRange() 95 | for i := f; i < c; i++ { 96 | if i % width == 0 { 97 | acc += fmt.Sprintf("\n\t[%02X] ", i) 98 | } 99 | acc += fmt.Sprintf("%02X ", dt.table[i]) 100 | } 101 | acc += "\n" 102 | return acc 103 | } 104 | 105 | func (dt *DataTable) Plot() string { 106 | scale := 0.50 107 | acc := "" 108 | f, c := dt.TransformRange() 109 | for i := f; i < c; i++ { 110 | v, _ := dt.Value(i) 111 | acc += fmt.Sprintf("[%02x] %02x ", i, v) 112 | for q := 0; q < int(float64(v) * scale); q++ { 113 | acc += "-" 114 | } 115 | acc += "\n" 116 | } 117 | return acc 118 | } 119 | 120 | /* 121 | ** TransformBank is a ProgramBank of DataTable. 122 | ** Implements: 123 | ** ProgramBank 124 | ** ChannelSelector (for single channel) 125 | ** Transform (for currently selected program) 126 | ** 127 | */ 128 | type TransformBank struct { 129 | programs []Transform 130 | current byte 131 | channelIndex MIDIChannelNibble 132 | } 133 | 134 | // NewTransformBank creates new TransformBank with n slots. 135 | // 136 | func NewTransformBank(n int) *TransformBank { 137 | p := make([]Transform, n) 138 | bank := TransformBank{p, 0, 0} 139 | for i := 0; i < n; i++ { 140 | p[i] = NewDataTable() 141 | } 142 | return &bank 143 | } 144 | 145 | // currentTransform returns the currently selected Transform. 146 | // 147 | func (bank *TransformBank) currentTransform() Transform { 148 | return bank.programs[bank.current] 149 | } 150 | 151 | // TransformRange() returns TransformRange of current transform. 152 | // 153 | func (bank *TransformBank) TransformRange() (floor byte, ceiling byte) { 154 | tr := bank.currentTransform() 155 | return tr.TransformRange() 156 | } 157 | 158 | // Reset() sets current transform to identity. 159 | // 160 | func (bank *TransformBank) Reset() { 161 | bank.currentTransform().Reset() 162 | } 163 | 164 | // Value() returns the indexed value from current transform. 165 | // Returns non-nil error if index is out of bounds. 166 | // 167 | func (bank *TransformBank) Value(index byte) (byte, error) { 168 | return bank.currentTransform().Value(index) 169 | } 170 | 171 | // SetValue() sets indexed position of current transform table. 172 | // Returns non-nil error if either index or value are out of bounds. 173 | // 174 | func (bank *TransformBank) SetValue(index byte, value byte) error { 175 | return bank.currentTransform().SetValue(index, value) 176 | } 177 | 178 | 179 | func (bank *TransformBank) ChannelMode() ChannelMode { 180 | return SingleChannel 181 | } 182 | 183 | // EnableChannel selects MIDI channel. 184 | // EnableChannel is required by the ChannelSelector interface, 185 | // The SelectChannel method is more convenient. 186 | // 187 | func (bank *TransformBank) EnableChannel(c MIDIChannel, _ bool) error { 188 | var err error 189 | ci := MIDIChannelNibble(c - 1) 190 | if ci < 0 || ci > 15 { 191 | msg := "Illegal MIDI channel: %d" 192 | err = fmt.Errorf(msg, byte(c)) 193 | } 194 | bank.channelIndex = ci 195 | return err 196 | } 197 | 198 | // SelectChannel selects indicated MIDI channel. 199 | // 200 | func (bank *TransformBank) SelectChannel(c MIDIChannel) error { 201 | return bank.EnableChannel(c, true) 202 | } 203 | 204 | // SelectedChannelIndexes returns slice of current MIDI channel. 205 | // 206 | func (bank *TransformBank) SelectedChannelIndexes() []MIDIChannelNibble { 207 | rs := make([]MIDIChannelNibble, 1) 208 | rs[0] = bank.channelIndex 209 | return rs 210 | } 211 | 212 | // ChannelIndexSelected returns true if argument is equal to current MIDI channel. 213 | // 214 | func (bank *TransformBank) ChannelIndexSelected(ci MIDIChannelNibble) bool { 215 | return ci == bank.channelIndex 216 | } 217 | 218 | // DeselectAllChannels() is required by ChannelSelector interface, does nothing. 219 | // 220 | func (bank *TransformBank) DeselectAllChannels(){} 221 | 222 | // SelectAllChannels() is required by ChannelSelector interface, does nothing. 223 | // 224 | func (bank *TransformBank) SelectAllChannels(){} 225 | 226 | // ProgramTransformRange() returns valid program-number range. 227 | // Program numbers outside this range are ignored. 228 | // 229 | func (bank *TransformBank) ProgramRange() (floor byte, ceiling byte) { 230 | floor, ceiling = byte(0), byte(len(bank.programs)) 231 | return 232 | } 233 | 234 | // CurrentProgram() returns the current program-number. 235 | // 236 | func (bank *TransformBank) CurrentProgram() byte { 237 | return bank.current 238 | } 239 | 240 | // ChangeProgram() selects a new current-program. 241 | // Out of range program-numbers are ignored. 242 | // 243 | func (bank *TransformBank) ChangeProgram(msg gomidi.Message) { 244 | st := StatusByte(msg.Data[0]) 245 | ci := MIDIChannelNibble(st & 0x0F) 246 | if st == PROGRAM && ci == bank.channelIndex { 247 | n := byte(msg.Data[1]) 248 | if 0 <= n && n < byte(len(bank.programs)) { 249 | bank.current = n 250 | } 251 | } 252 | } 253 | 254 | 255 | func (bank *TransformBank) String() string { 256 | s := "TransformBank(%d), channel: %d, current program: %d" 257 | return fmt.Sprintf(s, len(bank.programs), int(bank.channelIndex) + 1, bank.current) 258 | } 259 | -------------------------------------------------------------------------------- /op/cfilter.go: -------------------------------------------------------------------------------- 1 | package op 2 | 3 | import ( 4 | "fmt" 5 | gomidi "gitlab.com/gomidi/midi/v2" 6 | goosc "github.com/hypebeast/go-osc/osc" 7 | "github.com/plewto/pigiron/midi" 8 | ) 9 | 10 | // ChannelFilter is an Operator which selectively blocks MIDI channels. 11 | // Only MIDI messages with channels enabled are allowed through. 12 | // Separately non-channel messages may also be filtered. 13 | // 14 | type ChannelFilter struct { 15 | baseOperator 16 | enableSystemEvents bool 17 | } 18 | 19 | func newChannelFilter(name string) *ChannelFilter { 20 | op := new(ChannelFilter) 21 | initOperator(&op.baseOperator, "ChannelFilter", name, midi.MultiChannel) 22 | op.addCommandHandler("q-system-events-enabled", op.remoteQuerySystemEventsEnabled) 23 | op.addCommandHandler("enable-system-events", op.remoteEnableSystemEvents) 24 | op.Reset() 25 | return op 26 | } 27 | 28 | func (op *ChannelFilter) Reset() { 29 | op.enableSystemEvents = true 30 | for c := 1; c < 17; c++ { 31 | op.EnableChannel(midi.MIDIChannel(c), true) 32 | } 33 | base := &op.baseOperator 34 | base.Reset() 35 | } 36 | 37 | func (op *ChannelFilter) Send(msg gomidi.Message) { 38 | st := midi.StatusByte(msg.Data[0]) 39 | if midi.IsChannelStatus(st) { 40 | ci := midi.MIDIChannelNibble(st & 0x0F) 41 | if op.ChannelIndexSelected(ci) { 42 | op.distribute(msg) 43 | } 44 | } else { 45 | if op.enableSystemEvents { 46 | op.distribute(msg) 47 | } 48 | } 49 | } 50 | 51 | func (op *ChannelFilter) Info() string { 52 | s := op.commonInfo() 53 | s += fmt.Sprintf("\tenable system events: %v\n", op.enableSystemEvents) 54 | return s 55 | } 56 | 57 | 58 | // osc /pig/op name q-system-events-enabled 59 | // -> bool 60 | // 61 | func (op *ChannelFilter) remoteQuerySystemEventsEnabled(_ *goosc.Message)([]string, error) { 62 | var err error 63 | s := fmt.Sprintf("%v", op.enableSystemEvents) 64 | return []string{s}, err 65 | } 66 | 67 | 68 | // osc /pig/op name enable-system-events flag 69 | // -> Ack 70 | // 71 | func (op *ChannelFilter) remoteEnableSystemEvents(msg *goosc.Message)([]string, error) { 72 | args, err := ExpectMsg("ssb", msg) 73 | op.enableSystemEvents = args[2].B 74 | return empty, err 75 | } 76 | 77 | -------------------------------------------------------------------------------- /op/distributor.go: -------------------------------------------------------------------------------- 1 | package op 2 | 3 | 4 | import ( 5 | gomidi "gitlab.com/gomidi/midi/v2" 6 | "github.com/plewto/pigiron/midi" 7 | ) 8 | 9 | // Distributor is an Operator for changing MIDI channels. 10 | // Incoming channel-message are re-broadcast on each selected-channel. 11 | // The original message channel is ignored and all non-channel messages 12 | // are passed unchanged. 13 | // 14 | type Distributor struct { 15 | baseOperator 16 | } 17 | 18 | func newDistributor(name string) *Distributor { 19 | op := new(Distributor) 20 | initOperator(&op.baseOperator, "Distributor", name, midi.MultiChannel) 21 | op.Reset() 22 | return op 23 | } 24 | 25 | func (op *Distributor) Reset() { 26 | op.DeselectAllChannels() 27 | op.EnableChannel(midi.MIDIChannel(1), true) 28 | base := &op.baseOperator 29 | base.Reset() 30 | } 31 | 32 | func (op *Distributor) Send(msg gomidi.Message) { 33 | st := midi.StatusByte(msg.Data[0]) 34 | if midi.IsChannelStatus(st) { 35 | cmd := byte(st & 0xF0) 36 | for _, ci := range op.SelectedChannelIndexes() { 37 | msg.Data[0] = cmd | byte(ci) 38 | op.distribute(msg) 39 | } 40 | } else { 41 | op.distribute(msg) 42 | } 43 | } 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /op/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | ** op package implements the primary Pigiron object, the Operator. 3 | ** 4 | ** Operators are MIDI processing blocks which may be linked together into 5 | ** a MIDI "process-tree". Each operator has zero or more inputs (called 6 | ** it's parents) and zero or more outputs (children). On reception of a 7 | ** MIDI message an operator selectively forwards (a possibly modified) 8 | ** version of the message to all of it's children. 9 | ** 10 | ** The Operator interface defines the basic set of methods. The 11 | ** baseOperator struct provides a concrete implementation of Operator. 12 | ** 13 | ** The baseXformOperator, implemented in tbase.go, extends baseOperator to 14 | ** implement midi.Transform interface. 15 | ** 16 | */ 17 | 18 | package op 19 | -------------------------------------------------------------------------------- /op/dummy.go: -------------------------------------------------------------------------------- 1 | package op 2 | 3 | import midi "github.com/plewto/pigiron/midi" 4 | 5 | // DummyOperator is an Operator with no additional behavior. 6 | // Its sole purpose is for testing. 7 | // 8 | type DummyOperator struct { 9 | baseOperator 10 | } 11 | 12 | func newDummyOperator(name string) *DummyOperator { 13 | op := new(DummyOperator) 14 | initOperator(&op.baseOperator, "Dummy", name, midi.NoChannel) 15 | return op 16 | } 17 | 18 | 19 | -------------------------------------------------------------------------------- /op/expect.go: -------------------------------------------------------------------------------- 1 | package op 2 | 3 | /* 4 | ** expect.go provides OSC message argument validation. 5 | ** 6 | */ 7 | 8 | import ( 9 | "fmt" 10 | "strconv" 11 | "strings" 12 | goosc "github.com/hypebeast/go-osc/osc" 13 | "github.com/plewto/pigiron/midi" 14 | ) 15 | 16 | 17 | // StringSlice() embeds all arguments into string slice. 18 | // 19 | func StringSlice(values ...interface{}) []string { 20 | acc := make([]string, len(values)) 21 | for i, v := range values { 22 | acc[i] = fmt.Sprintf("%v", v) 23 | } 24 | return acc 25 | } 26 | 27 | 28 | // ToStringSlice() converts []interface slice into a string slice. 29 | // 30 | func ToStringSlice(values []interface{}) []string { 31 | acc := make([]string, len(values)) 32 | for i, v := range values { 33 | acc[i] = fmt.Sprintf("%v", v) 34 | } 35 | return acc 36 | } 37 | 38 | 39 | // ExpectValue struct holds return values for the Expect function. 40 | // Each field has a different type and the Expect function sets the appropriate 41 | // field based on its template argument. Only one field should ever have 42 | // a non-nil value. 43 | // 44 | type ExpectValue struct { 45 | S string 46 | I int64 47 | F float64 48 | B bool 49 | C midi.MIDIChannel 50 | O Operator} 51 | 52 | // Expect() validates a list of values for appropriate type. 53 | // Each character in the template indicates the expected value for the 54 | // corresponding position of the values list. 55 | // The possible template characters are: 56 | // s - string 57 | // i - int64 58 | // May prefix value with % for binary %1100 59 | // May prefix value with 0x for hex 0xff or 0XFF 60 | // Otherwise assume decimal 61 | // f - float64 62 | // b - bool (see strconv.ParseBool for excepted values) 63 | // c - MIDI channel (int 1 <= n <= 16) 64 | // o - Operator name 65 | // 66 | // The returned error is non-nil if either: 67 | // - len(template) > len(values) 68 | // - values[i] does not correspond to template[i] 69 | // 70 | // If no errors are detected the result is a list of ExpectValue of 71 | // length len(template), with the appropriate ExpectType field set 72 | // for each element. 73 | // 74 | func Expect(template string, values []interface{})([]ExpectValue, error) { 75 | 76 | trim := func(s string) string { 77 | return strings.Trim(strings.TrimSpace(s), ",") 78 | } 79 | 80 | var err error 81 | var acc []ExpectValue = make([]ExpectValue, len(template)) 82 | if len(template) > len(values) { 83 | msg := "Expected at least %d arguments, got %d" 84 | err = fmt.Errorf(msg, len(template), len(values)) 85 | return acc, err 86 | } 87 | 88 | for i, xtype := range template { 89 | arg := values[i] 90 | switch xtype { 91 | case 's': 92 | acc[i].S = trim(arg.(string)) 93 | case 'i': 94 | var s string = trim(fmt.Sprintf("%s", arg)) 95 | var n int64 = 0 96 | base := 10 97 | switch { 98 | case strings.HasPrefix(s, "%"): 99 | s = s[1:] 100 | base = 2 101 | case strings.HasPrefix(strings.ToLower(s), "0x"): 102 | s = s[2:] 103 | base = 16 104 | default: 105 | base = 10 106 | } 107 | n, err = strconv.ParseInt(s, base, 64) 108 | if err != nil { 109 | msg := "Expected int at index %d, got %v" 110 | err = fmt.Errorf(msg, i, arg, err) 111 | return acc, err 112 | } 113 | acc[i].I = n 114 | case 'f': 115 | var s string = trim(fmt.Sprintf("%s", arg)) 116 | var n float64 = 0.0 117 | n, err = strconv.ParseFloat(s, 64) 118 | if err != nil { 119 | msg := "Expected float at index %d, got %v" 120 | err = fmt.Errorf(msg, i, arg) 121 | return acc, err 122 | } 123 | acc[i].F = n 124 | case 'b': 125 | var s string = trim(fmt.Sprintf("%s", arg)) 126 | var v bool = false 127 | v, err = strconv.ParseBool(s) 128 | if err != nil { 129 | msg := "Expected bool at index %d, got %s" 130 | err = fmt.Errorf(msg, i, s) 131 | return acc, err 132 | } 133 | acc[i].B = v 134 | case 'c': 135 | var s string = trim(fmt.Sprintf("%d", arg)) 136 | var n int64 = 0 137 | n, err = strconv.ParseInt(s, 10, 64) 138 | if err != nil || n < 1 || 16 < n { 139 | msg := "Expected MIDI channel at index %d, got %v" 140 | err = fmt.Errorf(msg, i, arg) 141 | return acc, err 142 | } 143 | acc[i].C = midi.MIDIChannel(n) 144 | case 'o': 145 | var s string = trim(fmt.Sprintf("%s", arg)) 146 | var op Operator 147 | op, err = GetOperator(s) 148 | if err != nil { 149 | msg := "Expected Operator name at index %d, got %s" 150 | err = fmt.Errorf(msg, i, arg) 151 | return acc, err 152 | } 153 | acc[i].O = op 154 | default: 155 | msg := "Unknown Expect template type '%s'" 156 | err = fmt.Errorf(msg, xtype) 157 | panic(err) 158 | } 159 | } 160 | return acc, err 161 | } 162 | 163 | // ExpectMsg() is identical to Expect() but is applied to osc message arguments. 164 | // 165 | func ExpectMsg(template string, msg *goosc.Message)([]ExpectValue, error) { 166 | return Expect(template, msg.Arguments) 167 | } 168 | -------------------------------------------------------------------------------- /op/input.go: -------------------------------------------------------------------------------- 1 | package op 2 | 3 | import ( 4 | "fmt" 5 | gomidi "gitlab.com/gomidi/midi/v2" 6 | goosc "github.com/hypebeast/go-osc/osc" 7 | "github.com/plewto/pigiron/backend" 8 | "github.com/plewto/pigiron/midi" 9 | ) 10 | 11 | 12 | // MIDIInput is an Operator wrapper for MIDI input devices. 13 | // For each available MIDI device there may be only one corresponding MIDIInput. 14 | // If an attempt is made to create a MIDIInput for a device which is already 15 | // in use, the original MIDIInput is returned. 16 | // 17 | type MIDIInput struct { 18 | baseOperator 19 | port gomidi.In 20 | } 21 | 22 | var inputCache = make(map[string]*MIDIInput) 23 | 24 | func newMIDIInput(name string, port gomidi.In) (*MIDIInput, error) { 25 | op := new(MIDIInput) 26 | initOperator(&op.baseOperator, "MIDIInput", name, midi.NoChannel) 27 | op.addCommandHandler("q-device", op.remoteQueryDevice) 28 | op.port = port 29 | callback := func(msg gomidi.Message, delta int64) { 30 | if op.MIDIOutputEnabled() { 31 | op.Send(msg) 32 | } 33 | } 34 | listener, err := gomidi.NewListener(port, callback) 35 | if err != nil { 36 | msg := fmt.Sprintf("Can not set callback for MIDIInput %s", name) 37 | msg += fmt.Sprintf("\n%v", err) 38 | err = fmt.Errorf(msg) 39 | return nil, err 40 | } 41 | listener.StartListening() 42 | inputCache[port.String()] = op 43 | register(op) 44 | return op, err 45 | } 46 | 47 | func NewMIDIInput(name string, deviceSpec string) (*MIDIInput, error) { 48 | var op *MIDIInput 49 | port, err := backend.GetMIDIInput(deviceSpec) 50 | if err != nil { 51 | return op, err 52 | } 53 | op, cached := inputCache[port.String()] 54 | if !cached { 55 | op, err = newMIDIInput(name, port) 56 | if err != nil { 57 | return op, err 58 | } 59 | } 60 | return op, err 61 | } 62 | 63 | func (op *MIDIInput) String() string { 64 | msg := "%-12s name: \"%s\" device: \"%s\"" 65 | return fmt.Sprintf(msg, op.opType, op.name, op.port.String()) 66 | } 67 | 68 | // op.DeviceName() returns name for wrapped portmidi device. 69 | // 70 | func (op *MIDIInput) DeviceName() string { 71 | return op.port.String() 72 | } 73 | 74 | func (op *MIDIInput) Info() string { 75 | s := op.commonInfo() 76 | s += fmt.Sprintf("\tDevice Name : %s\n", op.port) 77 | return s 78 | } 79 | 80 | // op.remoteQueryDevice() extended osc handler for q-device 81 | // osc /pig/op , q-device 82 | // osc returns wrapped MIDI port name. 83 | // 84 | func (op *MIDIInput) remoteQueryDevice(_ *goosc.Message)([]string, error) { 85 | var err error 86 | name := fmt.Sprintf("\"%s\"", op.port) 87 | return []string{name}, err 88 | } 89 | 90 | -------------------------------------------------------------------------------- /op/operator.go: -------------------------------------------------------------------------------- 1 | package op 2 | 3 | import ( 4 | goosc "github.com/hypebeast/go-osc/osc" 5 | gomidi "gitlab.com/gomidi/midi/v2" 6 | "github.com/plewto/pigiron/midi" 7 | ) 8 | 9 | // Operator interface defines the primary pigiron object. 10 | // Operators are linked together as nodes on a MIDI process tree. 11 | // Each Operator has zero or more parents (inputs) and zero or more 12 | // children (outputs). When an Operator receives a MIDI message via it's 13 | // Send method, it selectively forwards it to all of it's child Operators. 14 | // The message may be modified prior to rebroadcasting it. 15 | // 16 | // The baseOperator struct implements the Operator interface and all 17 | // Operator like types should extend baseOperator. 18 | // 19 | // Method documentation is provided by baseOperator. 20 | // 21 | type Operator interface { 22 | midi.ChannelSelector 23 | OperatorType() string 24 | Name() string 25 | commonInfo() string 26 | Info() string 27 | Panic() 28 | Reset() 29 | Close() 30 | 31 | // Node 32 | IsRoot() bool 33 | IsLeaf() bool 34 | PrintTree() 35 | parents() map[string]Operator 36 | Parents() map[string]Operator 37 | children() map[string]Operator 38 | Children() map[string]Operator 39 | IsParentOf(child Operator) bool 40 | IsChildOf(parent Operator) bool 41 | circularTreeTest(depth int) bool 42 | Connect(child Operator) error 43 | Disconnect(child Operator) Operator 44 | DisconnectAll() 45 | DisconnectTree() 46 | DisconnectParents() 47 | 48 | // OSC 49 | DispatchCommand(command string, msg *goosc.Message)([]string, error) 50 | Commands() []string 51 | addCommandHandler(command string, handler func(*goosc.Message)([]string, error)) 52 | 53 | // MIDI 54 | MIDIOutputEnabled() bool 55 | SetMIDIOutputEnabled(flag bool) 56 | 57 | Accept(msg gomidi.Message) bool 58 | distribute(msg gomidi.Message) 59 | Send(msg gomidi.Message) 60 | } 61 | -------------------------------------------------------------------------------- /op/output.go: -------------------------------------------------------------------------------- 1 | package op 2 | 3 | import ( 4 | "C" 5 | "fmt" 6 | gomidi "gitlab.com/gomidi/midi/v2" 7 | goosc "github.com/hypebeast/go-osc/osc" 8 | "github.com/plewto/pigiron/backend" 9 | "github.com/plewto/pigiron/midi" 10 | ) 11 | 12 | // MIDIOutput is an Operator wrapper for MIDI output devices. 13 | // For each available MIDI device there may be only one corresponding MIDIOutput. 14 | // If an attempt is made to create a MIDIOutput for a device which is already 15 | // in use, the original MIDIOutput is returned. 16 | // 17 | type MIDIOutput struct { 18 | baseOperator 19 | port gomidi.Out 20 | 21 | } 22 | 23 | var outputCache = make(map[string]*MIDIOutput) 24 | 25 | 26 | // Creates new MIDIOutput, does not cache it. 27 | // Only called when cached version does not exists. 28 | // 29 | func newMIDIOutput(name string, port gomidi.Out) *MIDIOutput { 30 | var op *MIDIOutput 31 | op = new(MIDIOutput) 32 | initOperator(&op.baseOperator, "MIDIOutput", name, midi.NoChannel) 33 | op.port = port 34 | op.port.Open() 35 | op.addCommandHandler("q-device", op.remoteQueryDevice) 36 | outputCache[port.String()] = op 37 | register(op) 38 | return op 39 | } 40 | 41 | // Factory creates new MIDIOutput or grabs it from the cache. 42 | // 43 | 44 | func NewMIDIOutput(name string, deviceSpec string) (*MIDIOutput, error) { 45 | var err error 46 | var op *MIDIOutput 47 | var port gomidi.Out 48 | var cached bool 49 | port, err = backend.GetMIDIOutput(deviceSpec) 50 | if err != nil { 51 | return op, err 52 | } 53 | portName := port.String() 54 | if op, cached = outputCache[portName]; !cached { 55 | op = newMIDIOutput(name, port) 56 | } 57 | return op, err 58 | } 59 | 60 | 61 | 62 | func (op *MIDIOutput) String() string { 63 | msg := "%-12s name: \"%s\" device: \"%s\"" 64 | return fmt.Sprintf(msg, op.opType, op.name, op.port.String()) 65 | } 66 | 67 | func (op *MIDIOutput) DeviceName() string { 68 | return op.port.String() 69 | } 70 | 71 | func (op *MIDIOutput) Info() string { 72 | s := op.commonInfo() 73 | s += fmt.Sprintf("\tDevice Name : %s\n", op.port) 74 | return s 75 | } 76 | 77 | func (op *MIDIOutput) Send(msg gomidi.Message) { 78 | if op.MIDIOutputEnabled() { 79 | op.port.Send(msg.Data) 80 | op.distribute(msg) 81 | } 82 | } 83 | 84 | 85 | func (op *MIDIOutput) Panic() { 86 | fmt.Println("ISSUE: WARNING: MIDIOutput.Paninc not implemented") 87 | } 88 | 89 | 90 | // op.remoteQueryDevice() extended osc handler for q-device 91 | // osc /pig/op , q-device 92 | // osc returns wrapped port name. 93 | // 94 | func (op *MIDIOutput) remoteQueryDevice(_ *goosc.Message)([]string, error) { 95 | var err error 96 | id := fmt.Sprintf("%v", op.port) 97 | name := fmt.Sprintf("\"%s\"", op.port) 98 | return []string{id, name}, err 99 | } 100 | 101 | -------------------------------------------------------------------------------- /op/player.go: -------------------------------------------------------------------------------- 1 | package op 2 | 3 | import ( 4 | "time" 5 | "fmt" 6 | "github.com/plewto/pigiron/midi" 7 | "github.com/plewto/pigiron/pigerr" 8 | "github.com/plewto/pigiron/smf" 9 | gomidi "gitlab.com/gomidi/midi/v2" 10 | ) 11 | 12 | const ( 13 | PLAYER_START_DELAY = 200 // msec 14 | ) 15 | 16 | var ( 17 | ControllerDefaults = make(map[byte]byte) 18 | ) 19 | 20 | func init() { 21 | ControllerDefaults[1] = 0 22 | ControllerDefaults[2] = 0 23 | ControllerDefaults[4] = 127 24 | ControllerDefaults[7] = 127 25 | ControllerDefaults[10] = 64 26 | ControllerDefaults[11] = 127 27 | ControllerDefaults[64] = 0 28 | ControllerDefaults[65] = 0 29 | ControllerDefaults[123] = 127 // all notes off 30 | 31 | } 32 | 33 | type PlayerState byte 34 | const ( 35 | READY PlayerState = iota 36 | STOP 37 | STOPPING 38 | PLAYING 39 | ) 40 | 41 | func (st PlayerState) String() string { 42 | var s string 43 | switch st { 44 | case READY: s = "READY" 45 | case STOP: s = "STOP" 46 | case STOPPING: s = "STOPPING" 47 | case PLAYING: s = "PLAYING" 48 | default: 49 | s = "?" 50 | } 51 | return s 52 | } 53 | 54 | type MIDIPlayer struct { 55 | baseOperator 56 | midifile *smf.SMF 57 | noteQueue midi.NoteQueue 58 | state PlayerState 59 | eventIndex int 60 | tempo float64 61 | tempoScale float64 62 | tickDuration uint64 // μseconds 63 | currentTime uint64 // μseconds 64 | enableMIDITransport bool 65 | } 66 | 67 | func newMIDIPlayer(name string) *MIDIPlayer { 68 | op := new(MIDIPlayer) 69 | initOperator(&op.baseOperator, "MIDIPlayer", name, midi.NoChannel) 70 | op.midifile = smf.NewSMF() 71 | op.noteQueue = *midi.MakeNoteQueue() 72 | initTransportHandlers(op) 73 | op.enableMIDITransport = true 74 | go op.Reset() 75 | return op 76 | } 77 | 78 | func (op *MIDIPlayer) Reset() { 79 | op.killActiveNotes() 80 | op.resetControllers() 81 | op.state = READY 82 | } 83 | 84 | func (op *MIDIPlayer) MediaFilename() string { 85 | return op.midifile.Filename() 86 | } 87 | 88 | func (op *MIDIPlayer) LoadMedia(filename string) error { 89 | mf, err := smf.ReadSMF(filename) 90 | if err != nil { 91 | return err 92 | } 93 | op.midifile = mf 94 | return err 95 | } 96 | 97 | func (op *MIDIPlayer) Reload() error { 98 | var err error 99 | fname := op.MediaFilename() 100 | if fname == "" { 101 | errmsg := "Reload failed, No MIDI file specified" 102 | err = fmt.Errorf(errmsg) 103 | return err 104 | } 105 | err = op.LoadMedia(fname) 106 | return err 107 | } 108 | 109 | func (op *MIDIPlayer) resetControllers() { 110 | fmt.Println("Reseting controllers") 111 | for ci := byte(0); ci < 16; ci++ { 112 | st := byte(midi.CONTROLLER) | ci 113 | for ctrl, value := range ControllerDefaults { 114 | msg := gomidi.NewMessage([]byte{st, ctrl, value}) 115 | op.Send(msg) 116 | } 117 | time.Sleep(2 * time.Millisecond) 118 | op.Send(gomidi.NewMessage([]byte{st, 123, 0})) // Clear all-notes-off message 119 | st = byte(midi.BEND) | ci 120 | op.Send(gomidi.NewMessage([]byte{st, 64, 0})) // center bend. 121 | } 122 | } 123 | 124 | func (op *MIDIPlayer) killActiveNotes() { 125 | counter := 0 126 | for _, msg := range op.noteQueue.OffEvents() { 127 | op.distribute(msg) 128 | if counter % 16 == 0 { 129 | time.Sleep(2 * time.Millisecond) 130 | } 131 | counter++ 132 | } 133 | op.noteQueue.Reset() 134 | } 135 | 136 | 137 | func (op *MIDIPlayer) Stop() { 138 | fmt.Printf("\nMIDIPlayer %s: STOPPING\n", op.Name()) 139 | op.state = STOPPING 140 | time.Sleep(20 * time.Millisecond) 141 | op.killActiveNotes() 142 | op.resetControllers() 143 | op.state = READY 144 | fmt.Printf("\nMIDIPlayer %s: STOPPED\n", op.Name()) 145 | } 146 | 147 | func (op *MIDIPlayer) Continue() error { 148 | var err error 149 | if op.MediaFilename() == "" { 150 | errmsg := "No MIDI file loaded" 151 | err = fmt.Errorf(errmsg) 152 | return err 153 | } 154 | op.noteQueue.Reset() 155 | go op.playLoop() 156 | return err 157 | } 158 | 159 | func (op *MIDIPlayer) Play() error { 160 | var err error 161 | err = op.Reload() 162 | if err != nil { 163 | return err 164 | } 165 | op.currentTime = 0 166 | op.eventIndex = 0 167 | err = op.Continue() 168 | return err 169 | } 170 | 171 | func (op *MIDIPlayer) playLoop() error { 172 | var err error 173 | var track smf.Track 174 | if !(op.state == READY) { 175 | errmsg := "MIDIPlayer %s is not ready, try again in a few seconds." 176 | err = fmt.Errorf(errmsg, op.Name()) 177 | return err 178 | } 179 | op.state = PLAYING 180 | time.Sleep(PLAYER_START_DELAY * time.Millisecond) 181 | fmt.Printf("\nMIDIPlayer %s: PLAYING\n", op.Name()) 182 | track, err = op.midifile.Track(0) 183 | if err != nil { 184 | return err 185 | } 186 | events := track.Events() 187 | op.eventIndex = 0 188 | for op.eventIndex < len(events) { 189 | event := events[op.eventIndex] 190 | delay := time.Duration(op.tickDuration * event.DeltaTime()) 191 | time.Sleep(delay * time.Microsecond) 192 | d := event.Message().Data 193 | if len(d) == 0 { 194 | continue 195 | } 196 | st := midi.StatusByte(d[0]) 197 | msg := event.Message() 198 | switch { 199 | case midi.IsChannelStatus(st): 200 | op.distribute(msg) 201 | op.noteQueue.Update(msg) 202 | case midi.IsSystemStatus(st): 203 | op.distribute(msg) 204 | case midi.IsMetaStatus(st): 205 | var exitFlag bool 206 | exitFlag, err = op.handleMeta(msg) 207 | if exitFlag || err != nil { 208 | break 209 | } 210 | default: 211 | // ignore 212 | } 213 | if op.state != PLAYING { 214 | break 215 | } 216 | op.currentTime += uint64(delay) 217 | op.eventIndex++ 218 | } 219 | op.Stop() 220 | return err 221 | } 222 | 223 | func (op *MIDIPlayer) handleMeta(msg gomidi.Message) (exitFlag bool, err error) { 224 | d := msg.Data 225 | if len(d) < 2 { 226 | errmsg := "Malformed meta message at event index %d" 227 | err = fmt.Errorf(errmsg, op.eventIndex) 228 | return 229 | } 230 | mtype := midi.MetaType(msg.Data[1]) 231 | switch { 232 | case smf.IsTempoChange(msg): 233 | op.tempo, err = smf.MetaTempoBPM(msg) 234 | if err != nil { 235 | errmsg := "Meta tempo message looks weird, using default 120 BPM" 236 | pigerr.Warning(errmsg, err.Error()) 237 | op.tempo = 120.0 238 | } 239 | div := op.midifile.Division() 240 | tck := smf.TickDuration(div, op.tempo) 241 | op.tickDuration = uint64(tck * 1e6) 242 | exitFlag, err = false, nil 243 | return 244 | case smf.IsTextMessage(msg): 245 | tm := smf.FormatTime(op.Position()) 246 | tx, txt, _ := smf.ExtractMetaText(msg) 247 | fmt.Printf("time %s %s : %s\n", tm, midi.MetaType(txt), tx) 248 | case mtype == midi.META_END_OF_TRACK: 249 | exitFlag = true 250 | default: 251 | // ignore 252 | } 253 | return exitFlag, err 254 | } 255 | 256 | func (op *MIDIPlayer) IsReady() bool { 257 | return op.state == READY 258 | } 259 | 260 | 261 | func (op *MIDIPlayer) IsPlaying() bool { 262 | return op.state == PLAYING 263 | } 264 | 265 | func (op *MIDIPlayer) Duration() float64 { 266 | if op.MediaFilename() == "" { 267 | return 0 268 | } else { 269 | return op.midifile.Duration() 270 | } 271 | } 272 | 273 | func (op *MIDIPlayer) Position() float64 { 274 | return float64(op.currentTime / 1e6) 275 | } 276 | 277 | func (op *MIDIPlayer) EnableMIDITransport(flag bool) { 278 | op.enableMIDITransport = flag 279 | } 280 | 281 | func (op *MIDIPlayer) MIDITransportEnabled() bool { 282 | return op.enableMIDITransport 283 | } 284 | -------------------------------------------------------------------------------- /op/registry.go: -------------------------------------------------------------------------------- 1 | package op 2 | 3 | import ( 4 | "time" 5 | "fmt" 6 | "errors" 7 | ) 8 | 9 | var OperatorTypes = []string{ 10 | "ChannelFilter", 11 | "SingleChannelFilter", 12 | "Disrtributor", 13 | "MIDIInput", 14 | "MIDIOutput", 15 | "MIDIPlayer", 16 | "Monitor", 17 | "Transformer"} 18 | 19 | // The registry is a global map holding all current operators. 20 | // MIDIInput and MIDIOutput operators are stored separately. 21 | // 22 | var registry map[string]Operator = make(map[string]Operator) 23 | 24 | 25 | // OperatorExists(name) returns true if the registry contains the named operator. 26 | // 27 | func OperatorExists(name string) bool { 28 | _, flag := registry[name] 29 | return flag 30 | } 31 | 32 | 33 | // register() adds an operator to the registry. 34 | // Returns operator's name 35 | // 36 | func register(op Operator) string { 37 | registry[op.Name()] = op 38 | return op.Name() 39 | } 40 | 41 | 42 | // DumpRegistry() prints the contents of the operator registry. 43 | // 44 | func DumpRegistry() { 45 | fmt.Println("Operator registry:") 46 | for _, op := range registry { 47 | fmt.Printf("\t%s", op) 48 | } 49 | } 50 | 51 | 52 | // NewOperator function creates a new Operator and adds it to the registry. 53 | // All operators should be created by NewOperator. 54 | // 55 | // opType indicates the type of Operator 56 | // 57 | // Returns the new Operator and an error. 58 | // The error is non-nil if: 59 | // 1) opType was invalid 60 | // 2) if an Operator name exists and its type does not match opType. 61 | // 62 | // If Operator name exist with the same type as opType, the existing 63 | // operator is reused. 64 | // 65 | func NewOperator(opType string, name string) (Operator, error) { 66 | var err error 67 | var op Operator 68 | if OperatorExists(name) { 69 | other, _ := registry[name] 70 | if other.OperatorType() != opType { 71 | msg := "An operator named %s of type %s already exists\n" 72 | msg += "Can not create new %s Operator with same name." 73 | err = fmt.Errorf(msg, name, other.OperatorType(), opType) 74 | return op, err 75 | } else { 76 | return GetOperator(name) 77 | } 78 | } 79 | switch opType { 80 | case "Dummy": 81 | op = newDummyOperator(name) 82 | case "Monitor": 83 | op = newMonitor(name) 84 | case "ChannelFilter": 85 | op = newChannelFilter(name) 86 | case "SingleChannelFilter": 87 | op = newSingleChannelFilter(name) 88 | case "Distributor": 89 | op = newDistributor(name) 90 | case "MIDIPlayer": 91 | op = newMIDIPlayer(name) 92 | case "Transformer": 93 | op = newTransformer(name) 94 | default: 95 | sfmt := "Invalid Operator type: '%s'" 96 | msg := fmt.Sprintf(sfmt, opType) 97 | err = errors.New(msg) 98 | return op, err 99 | } 100 | register(op) 101 | return op, err 102 | } 103 | 104 | // DeleteOperator() Deletes named operator. 105 | // Returns error if operator does not exists or it is a MIDIInput. 106 | // 107 | func DeleteOperator(name string) error { 108 | var err error 109 | var op Operator 110 | op, err = GetOperator(name) 111 | if err != nil { 112 | return err 113 | } 114 | if op.OperatorType() == "MIDIInput" { 115 | msg := "Can not delete MIDIInput Operator: %s" 116 | err = fmt.Errorf(msg, name) 117 | return err 118 | } 119 | op.Panic() 120 | time.Sleep(1 * time.Millisecond) 121 | op.DisconnectAll() 122 | op.Close() 123 | delete(registry, name) 124 | return err 125 | } 126 | 127 | 128 | 129 | // ClearRegistry() Deletes all Operators (except MIDIInputs) 130 | // 131 | func ClearRegistry() { 132 | for _, root := range RootOperators() { 133 | root.Panic() 134 | } 135 | time.Sleep(10 * time.Millisecond) 136 | for _, op := range Operators() { 137 | op.DisconnectAll() 138 | if op.OperatorType() != "MIDIInput" { 139 | delete(registry, op.Name()) 140 | op.Close() 141 | } 142 | } 143 | } 144 | 145 | 146 | // GetOperator() returns named operator. 147 | // Returns: 148 | // 1. The operator 149 | // 2. non-nil error if the operator does not exists. 150 | // 151 | func GetOperator(name string) (Operator, error) { 152 | var op Operator 153 | var err error 154 | if OperatorExists(name) { 155 | op = registry[name] 156 | } else { 157 | sfmt := "Operator '%s' does not exists" 158 | msg := fmt.Sprintf(sfmt, name) 159 | err = errors.New(msg) 160 | } 161 | return op, err 162 | } 163 | 164 | 165 | // Operators() returns unordered slice of all current operators. 166 | // 167 | func Operators() []Operator { 168 | var acc = make([]Operator, 0, len(registry)) 169 | for _, op := range(registry) { 170 | acc = append(acc, op) 171 | } 172 | return acc 173 | } 174 | 175 | // RootOperators() returns slice of all root operators. 176 | // 177 | func RootOperators() []Operator { 178 | var acc = make([]Operator, 0, len(registry)) 179 | for _, op := range Operators() { 180 | if op.IsRoot() { 181 | acc = append(acc, op) 182 | } 183 | } 184 | return acc 185 | } 186 | 187 | 188 | func DestroyForest() { 189 | for _, root := range RootOperators() { 190 | root.DisconnectTree() 191 | } 192 | } 193 | 194 | func Cleanup() { 195 | for _, op := range registry { 196 | op.Close() 197 | } 198 | } 199 | 200 | 201 | func ResetAll() { 202 | for _, op := range Operators() { 203 | op.Reset() 204 | } 205 | } 206 | 207 | func PanicAll() { 208 | for _, op := range RootOperators() { 209 | op.Panic() 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /op/scfilter.go: -------------------------------------------------------------------------------- 1 | package op 2 | 3 | import ( 4 | "fmt" 5 | gomidi "gitlab.com/gomidi/midi/v2" 6 | goosc "github.com/hypebeast/go-osc/osc" 7 | "github.com/plewto/pigiron/midi" 8 | ) 9 | 10 | // SingleChannelFilter is a restricted form of ChannelFilter. 11 | // which only passes messages from a single MIDI channel. 12 | // non-channel messages may optionaly be blocked. 13 | // 14 | type SingleChannelFilter struct { 15 | baseOperator 16 | enableSystemEvents bool 17 | } 18 | 19 | func newSingleChannelFilter(name string) *SingleChannelFilter { 20 | op := new(SingleChannelFilter) 21 | initOperator(&op.baseOperator, "SingleChannelFilter", name, midi.SingleChannel) 22 | op.addCommandHandler("q-system-events-enabled", op.remoteQuerySystemEventsEnabled) 23 | op.addCommandHandler("enable-system-events", op.remoteEnableSystemEvents) 24 | op.Reset() 25 | return op 26 | } 27 | 28 | func (op *SingleChannelFilter) Reset() { 29 | op.enableSystemEvents = true 30 | op.EnableChannel(midi.MIDIChannel(1), true) 31 | base := &op.baseOperator 32 | base.Reset() 33 | } 34 | 35 | func (op *SingleChannelFilter) Send(msg gomidi.Message) { 36 | st := midi.StatusByte(msg.Data[0]) 37 | if midi.IsChannelStatus(st) { 38 | ci := midi.MIDIChannelNibble(st & 0x0F) 39 | if op.ChannelIndexSelected(ci) { 40 | op.distribute(msg) 41 | } 42 | } else { 43 | if op.enableSystemEvents { 44 | op.distribute(msg) 45 | } 46 | } 47 | } 48 | 49 | func (op *SingleChannelFilter) Info() string { 50 | s := op.commonInfo() 51 | s += fmt.Sprintf("\tenable system events: %v\n", op.enableSystemEvents) 52 | return s 53 | } 54 | 55 | 56 | // osc /pig/op name q-system-events-enabled 57 | // -> bool 58 | // 59 | func (op SingleChannelFilter) remoteQuerySystemEventsEnabled(_ *goosc.Message)([]string, error) { 60 | var err error 61 | s := fmt.Sprintf("%v", op.enableSystemEvents) 62 | return []string{s}, err 63 | } 64 | 65 | 66 | // osc /pig/op name enable-system-events flag 67 | // -> Ack 68 | // 69 | func (op SingleChannelFilter) remoteEnableSystemEvents(msg *goosc.Message)([]string, error) { 70 | args, err := ExpectMsg("ssb", msg) 71 | op.enableSystemEvents = args[2].B 72 | return empty, err 73 | } 74 | -------------------------------------------------------------------------------- /op/tbase.go: -------------------------------------------------------------------------------- 1 | package op 2 | 3 | import ( 4 | "fmt" 5 | goosc "github.com/hypebeast/go-osc/osc" 6 | "github.com/plewto/pigiron/midi" 7 | ) 8 | 9 | /* 10 | ** baseXformOperator extends baseOperator to implement midi.Transform 11 | ** 12 | */ 13 | type baseXformOperator struct { 14 | baseOperator 15 | xformTable midi.DataTable 16 | } 17 | 18 | 19 | func (op *baseXformOperator) Reset() { 20 | base := &op.baseOperator 21 | xform := &op.xformTable 22 | base.Reset() 23 | xform.Reset() 24 | } 25 | 26 | func (xop *baseXformOperator) TransformRange() (floor byte, ceiling byte) { 27 | return xop.xformTable.TransformRange() 28 | } 29 | 30 | func (xop *baseXformOperator) Value(index byte) (value byte, err error) { 31 | return xop.xformTable.Value(index) 32 | } 33 | 34 | func (xop *baseXformOperator) SetValue(index byte, value byte) error { 35 | return xop.xformTable.SetValue(index, value) 36 | } 37 | 38 | func (xop *baseXformOperator) Dump() string { 39 | return xop.xformTable.Dump() 40 | } 41 | 42 | func (xop *baseXformOperator) Plot() string { 43 | return xop.xformTable.Plot() 44 | } 45 | 46 | 47 | func initXformOperator(xop *baseXformOperator) { 48 | 49 | // cmd op name, q-table-range 50 | // osc /pig/op name, q-table-range 51 | // Returns table index range: 52 | // [floor, ceiling] 53 | // 54 | remoteQueryRange := func(msg *goosc.Message)([]string, error) { 55 | var err error 56 | f, c := xop.TransformRange() 57 | sf, sc := fmt.Sprintf("0x%02X", f), fmt.Sprintf("0x%02X", c) 58 | return []string{sf, sc}, err 59 | } 60 | 61 | // cmd op name, q-table-value, index 62 | // osc /pig/op name, q-table-value, index 63 | // Returns 64 | // Indexed value from transform table 65 | // Error if index is out of bounds 66 | // 67 | remoteQueryValue := func(msg *goosc.Message)([]string, error) { 68 | args, err := ExpectMsg("osi", msg) 69 | if err != nil { 70 | return empty, err 71 | } 72 | var index byte = byte(args[2].I) 73 | var value byte 74 | value, err = xop.Value(index) 75 | s := fmt.Sprintf("0x%02X", value) 76 | return []string{s}, err 77 | } 78 | 79 | // cmd op name, set-table-value, index, value, [values ...] 80 | // osc /pig/op name, set-table-value, index, value, [values ...] 81 | // Returns error if either index or value are out of bounds. 82 | // 0 <= index < ceiling 83 | // 0 <= value < 0x80 84 | // 85 | remoteSetValue := func(msg *goosc.Message)([]string, error) { 86 | template := "osii" 87 | for i := 4; i < len(msg.Arguments); i++ { 88 | template += "i" 89 | } 90 | args, err := ExpectMsg(template, msg) 91 | if err != nil { 92 | return empty, err 93 | } 94 | var index byte = byte(args[2].I) 95 | count := len(template) - 3 96 | for i := 0; i < count; i++ { 97 | value := byte(args[i+3].I) 98 | err = xop.xformTable.SetValue(index, value) 99 | if err != nil { 100 | break 101 | } 102 | index++ 103 | } 104 | return empty, err 105 | } 106 | 107 | 108 | // cmd op name, print-table 109 | // osc /pig/op name, print-table 110 | // 111 | // Display hex-dump of transformation table. 112 | // 113 | remoteDumpTable := func(msg *goosc.Message)([]string, error) { 114 | var err error 115 | fmt.Printf("%s\n", xop.Dump()) 116 | fmt.Printf("%s", xop.Plot()) 117 | return empty, err 118 | } 119 | 120 | xop.addCommandHandler("q-table-range", remoteQueryRange) 121 | xop.addCommandHandler("q-table-value", remoteQueryValue) 122 | xop.addCommandHandler("set-table-value", remoteSetValue) 123 | xop.addCommandHandler("print-table", remoteDumpTable) 124 | 125 | } 126 | 127 | -------------------------------------------------------------------------------- /op/transformer.go: -------------------------------------------------------------------------------- 1 | package op 2 | 3 | import ( 4 | "fmt" 5 | gomidi "gitlab.com/gomidi/midi/v2" 6 | goosc "github.com/hypebeast/go-osc/osc" 7 | "github.com/plewto/pigiron/midi" 8 | ) 9 | 10 | // Transformer is an Operator which selectivlty modifies MIDI data bytes. 11 | // Either data byte of any channel message may be modified. 12 | // Transformer implements the Operator and midi.Transform interfaces. 13 | // 14 | type Transformer struct { 15 | baseXformOperator 16 | status midi.StatusByte 17 | dataNumber midi.DataNumber 18 | } 19 | 20 | func newTransformer (name string) *Transformer { 21 | op := new(Transformer) 22 | op.status = midi.KEYED_STATUS 23 | op.dataNumber = midi.DATA_1 24 | initOperator(&op.baseOperator, "Transformer", name, midi.NoChannel) 25 | initXformOperator(&op.baseXformOperator) 26 | op.initLocalHandlers() 27 | op.Reset() 28 | return op 29 | } 30 | 31 | func (op *Transformer) Reset() { 32 | xbase := &op.baseXformOperator 33 | xbase.Reset() 34 | op.status = midi.KEYED_STATUS 35 | op.dataNumber = midi.DATA_1 36 | } 37 | 38 | func (op *Transformer) Info() string { 39 | s := op.commonInfo() 40 | s += fmt.Sprintf("\tStatus : 0x%02X %s\n", byte(op.status), op.status) 41 | s += fmt.Sprintf("\tData byte : %s\n", op.dataNumber) 42 | s += fmt.Sprintf("%s\n", op.Dump()) 43 | return s 44 | } 45 | 46 | func (op *Transformer) Send(msg gomidi.Message) { 47 | st := midi.StatusByte(msg.Data[0]) 48 | keyed := midi.IsKeyedStatus(st) 49 | if st == op.status || ((op.status == midi.KEYED_STATUS) && keyed) { 50 | if op.dataNumber == midi.DATA_2 { 51 | msg.Data[2], _ = op.Value(msg.Data[2]) 52 | } else { 53 | msg.Data[1], _ = op.Value(msg.Data[1]) 54 | } 55 | } 56 | op.distribute(msg) 57 | } 58 | 59 | 60 | func (op *Transformer) initLocalHandlers() { 61 | 62 | channelStats := map[int64]string{0x00 : "DISABLED", 63 | 0x01 : "KEYED", 64 | 0x80 : "NOTE_OFF", 65 | 0x90 : "NOTE_ON", 66 | 0xA0 : "POLY_PRESSURE", 67 | 0xB0 : "CONTROLLER", 68 | 0xC0 : "PROGRAM", 69 | 0xD0 : "MONO_PRESSURE", 70 | 0xE0 : "PITCH_BEND"} 71 | 72 | isChannelStatus := func(n int64) error { 73 | var err error 74 | _, flag := channelStats[n] 75 | if !flag { 76 | msg := "Expected valid status value to Transformer, got 0x%02X" 77 | err = fmt.Errorf(msg, n) 78 | } 79 | return err 80 | } 81 | 82 | 83 | // cmd op name, select-status, status 84 | // osc pig/op name, set-status, status 85 | // 86 | // status 0x00 - DISABLE 87 | // 0x01 - KEY_OFF & KEY_ON 88 | // 0x80 - KEY_OFF 89 | // 0x90 - KEY_ON 90 | // 0xA0 - POLY_PRESSURE 91 | // 0xB0 - CONTROLLER 92 | // 0xC0 - PROGRAM 93 | // 0xD0 - MONO_PRESSURE 94 | // 0xE0 - PITCH_BEND 95 | // 96 | remoteSetStatus := func(msg *goosc.Message)([]string, error) { 97 | args, err := ExpectMsg("osi", msg) 98 | if err != nil { 99 | return empty, err 100 | } 101 | st := args[2].I 102 | err = isChannelStatus(st) 103 | if err != nil { 104 | return empty, err 105 | } 106 | op.status = midi.StatusByte(st) 107 | return empty, err 108 | } 109 | 110 | // cmd op name, q-status 111 | // osc /pig/op name, q-status 112 | // 113 | // Returns: Selected MIDI status byte. 114 | // 115 | remoteQueryStatus := func(msg *goosc.Message)([]string, error) { 116 | _, err := ExpectMsg("os", msg) 117 | if err != nil { 118 | return empty, err 119 | } 120 | st := fmt.Sprintf("0x%02X", byte(op.status)) 121 | return []string{st}, err 122 | } 123 | 124 | // cmd op name, select-data-byte, n where n = 1 or 2 125 | // osc /pig/op name, select-data, n 126 | // 127 | remoteSelectDataByte := func(msg *goosc.Message)([]string, error) { 128 | args, err := ExpectMsg("osi", msg) 129 | if err != nil { 130 | return empty, err 131 | } 132 | n := args[2].I 133 | switch n { 134 | case 1: 135 | op.dataNumber = midi.DATA_1 136 | case 2: 137 | op.dataNumber = midi.DATA_2 138 | default: 139 | msg := "Expected data byte 1 or 2, got %d" 140 | err = fmt.Errorf(msg, n) 141 | } 142 | return empty, err 143 | } 144 | 145 | // cmd op name, q-data-byte 146 | remoteQuerySelectedDataByte := func(msg *goosc.Message)([]string, error) { 147 | var err error 148 | var s string 149 | switch op.dataNumber { 150 | case midi.DATA_1: s = "1" 151 | case midi.DATA_2: s = "2" 152 | default: s = "?" 153 | } 154 | return []string{s}, err 155 | } 156 | 157 | op.addCommandHandler("select-status", remoteSetStatus) 158 | op.addCommandHandler("q-status", remoteQueryStatus) 159 | op.addCommandHandler("select-data-byte", remoteSelectDataByte) 160 | op.addCommandHandler("q-data-byte", remoteQuerySelectedDataByte) 161 | } 162 | 163 | 164 | -------------------------------------------------------------------------------- /op/transport.go: -------------------------------------------------------------------------------- 1 | package op 2 | 3 | import ( 4 | "fmt" 5 | goosc "github.com/hypebeast/go-osc/osc" 6 | ) 7 | 8 | 9 | // Transport interface defines all media players. 10 | // 11 | // All types which implement Transport should call initTransportHandlers() 12 | // during construction. 13 | // 14 | // t.Name() The operators name. 15 | // 16 | // t.Stop() halts playback. 17 | // osc command /pig/op , stop 18 | // 19 | // t.Play() commence playback starting at beginning. 20 | // Returns non-nil error if no media has been loaded. 21 | // osc command /pig/op , play 22 | // 23 | // t.Continue() continues playback from current position. 24 | // Returns non-nil error if no media has been loaded. 25 | // osc command /pig/op , continue 26 | // 27 | // t.IsPlaying() returns true if playback is current in progress. 28 | // osc command /pig/op , q-is-playing 29 | // osc returns bool 30 | // 31 | // t.LoadMedia() loads named media file. 32 | // Returns non-nil error if file could not be loaded. 33 | // osc command /pig/op , load, 34 | // osc returns error if file could not be loaded. 35 | // 36 | // t.MediaFilename() returns filename for currently loaded media. 37 | // Returns empty-string if no media is loaded. 38 | // osc command /pig/op , q-media-filename 39 | // osc returns filename 40 | // 41 | // t.Duration() returns approximate media length in seconds. 42 | // osc command /pig/op , q-duration 43 | // osc returns int time in milliseconds. 44 | // 45 | // t.Position() returns current playback position in seconds. 46 | // osc command /pig/op , q-position 47 | // osc returns int time in milliseconds. 48 | // 49 | // t.EnableMIDITransport() enable/disable MIDI transport control. 50 | // If enabled the player will stop/start/continue on reception 51 | // of corresponding MIDI system commands. 52 | // osc command /pig/op , enable-midi-transport, 53 | // osc returns ACK 54 | // 55 | // t.MIDITransportEnabled() returns true if MIDItransport is enabled. 56 | // osc command /pig/op , q-midi-transport-enabled 57 | // osc returns bool 58 | // 59 | type Transport interface { 60 | Name() string 61 | Stop() 62 | Play() error 63 | Continue() error 64 | IsPlaying() bool 65 | LoadMedia(filename string) error 66 | MediaFilename() string 67 | Duration() float64 68 | Position() float64 69 | EnableMIDITransport(flag bool) 70 | MIDITransportEnabled() bool 71 | addCommandHandler(command string, handler func(*goosc.Message)([]string, error)) 72 | } 73 | 74 | // initTransportHandlers() adds transport OSC handlers. 75 | // All type implementing Transport should call initTransportHandlers() 76 | // during construction. 77 | // 78 | func initTransportHandlers(transport Transport) { 79 | 80 | formatResponse := func(command string, values ...string) []string { 81 | acc := make([]string, 0, len(values) + 1) 82 | acc = append(acc, fmt.Sprintf("subcommand = %s.%s", transport.Name(), command)) 83 | for _, v := range values { 84 | acc = append(acc, v) 85 | } 86 | return acc 87 | } 88 | 89 | formatErrorResponse := func(command string) []string { 90 | acc := []string{fmt.Sprintf("subcommand = %s.%s", transport.Name(), command)} 91 | return acc 92 | } 93 | 94 | 95 | // /pig/op , stop 96 | // 97 | remoteStop := func(msg *goosc.Message) ([]string, error) { 98 | var err error 99 | transport.Stop() 100 | return formatResponse("stop"), err 101 | } 102 | 103 | // /pig/op , play 104 | // 105 | remotePlay := func(msg *goosc.Message) ([]string, error) { 106 | err := transport.Play() 107 | if err != nil { 108 | rs := formatErrorResponse("play") 109 | return rs, err 110 | } 111 | rs := formatResponse("play", transport.MediaFilename()) 112 | return rs, err 113 | } 114 | 115 | // /pig/op , continue 116 | // 117 | remoteContinue := func(msg *goosc.Message) ([]string, error) { 118 | err := transport.Continue() 119 | if err != nil { 120 | return formatErrorResponse("continue"), err 121 | } 122 | return formatResponse("continue"), err 123 | } 124 | 125 | // /pig/op load 126 | // 127 | remoteLoad := func(msg *goosc.Message) ([]string, error) { 128 | value, err := ExpectMsg("oss", msg) 129 | if err != nil { 130 | return formatErrorResponse("load"), err 131 | } 132 | filename := value[2].S 133 | err = transport.LoadMedia(filename) 134 | if err != nil { 135 | return formatErrorResponse("load"), err 136 | } 137 | return formatResponse("load", filename), err 138 | } 139 | 140 | // op , enable-midi-transport, 141 | // 142 | remoteEnableMIDITransport := func(msg *goosc.Message) ([]string, error) { 143 | value, err := ExpectMsg("osb", msg) 144 | if err != nil { 145 | return formatErrorResponse("enable-midi-transport"), err 146 | } 147 | flag := value[2].B 148 | transport.EnableMIDITransport(flag) 149 | return formatResponse("enable-midi-transport", fmt.Sprintf("%v", flag)), err 150 | } 151 | 152 | // op q-midi-transport-enabled --> bool 153 | // 154 | remoteQueryMIDITransport := func(msg *goosc.Message) ([]string, error) { 155 | var err error 156 | flag := fmt.Sprintf("%v", transport.MIDITransportEnabled()) 157 | return formatResponse("q-midi-transport-enabled", fmt.Sprintf("%v", flag)), err 158 | } 159 | 160 | // op q-is-playing --> bool 161 | // 162 | remoteQueryIsPlaying := func(msg *goosc.Message) ([]string, error) { 163 | var err error 164 | flag := fmt.Sprintf("%v", transport.IsPlaying()) 165 | return formatResponse("q-is-playing", fmt.Sprintf("%v", flag)), err 166 | } 167 | 168 | // op q-duration --> time(sec) 169 | // 170 | remoteQueryDuration := func(msg *goosc.Message) ([]string, error) { 171 | var err error 172 | dur := fmt.Sprintf("%d", transport.Duration()) 173 | return formatResponse("q-duration", dur), err 174 | } 175 | 176 | // op q-position --> time(sec) 177 | // 178 | remoteQueryPosition := func(msg *goosc.Message) ([]string, error) { 179 | var err error 180 | pos := fmt.Sprintf("%d", transport.Position()) 181 | return formatResponse("q-position", pos), err 182 | } 183 | 184 | // op q-media-filename --> filename 185 | // 186 | remoteQueryMediaName := func(msg *goosc.Message) ([]string, error) { 187 | var err error 188 | name := transport.MediaFilename() 189 | return formatResponse("q-media-name", name), err 190 | } 191 | 192 | transport.addCommandHandler("stop", remoteStop) 193 | transport.addCommandHandler("play", remotePlay) 194 | transport.addCommandHandler("continue", remoteContinue) 195 | transport.addCommandHandler("load", remoteLoad) 196 | transport.addCommandHandler("enable-midi-transport", remoteEnableMIDITransport) 197 | transport.addCommandHandler("q-midi-transport-enabled", remoteQueryMIDITransport) 198 | transport.addCommandHandler("q-is-playing", remoteQueryIsPlaying) 199 | transport.addCommandHandler("q-duration", remoteQueryDuration) 200 | transport.addCommandHandler("q-position", remoteQueryPosition) 201 | transport.addCommandHandler("q-media-filename", remoteQueryMediaName) 202 | } 203 | -------------------------------------------------------------------------------- /osc/batch.go: -------------------------------------------------------------------------------- 1 | package osc 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "os" 7 | "strings" 8 | "github.com/plewto/pigiron/config" 9 | "github.com/plewto/pigiron/pigpath" 10 | "github.com/plewto/pigiron/piglog" 11 | ) 12 | 13 | 14 | func batchFileExist(name string) (filename string, flag bool) { 15 | if len(name) == 0 { 16 | return "", false 17 | } 18 | filename = pigpath.Join(config.GlobalParameters.BatchDirectory, name) 19 | _, err := os.Stat(filename) 20 | if os.IsNotExist(err) { 21 | return filename, false 22 | } else { 23 | return filename, true 24 | } 25 | } 26 | 27 | 28 | func printBatchError(filename string, err error) { 29 | fmt.Print(config.GlobalParameters.ErrorColor) 30 | fmt.Printf("Can not read batch file '%s'\n", filename) 31 | fmt.Printf("%s\n", err) 32 | } 33 | 34 | // BatchLoad() loads batch file. 35 | // A batch file is a sequence of osc commands with identical syntax to 36 | // interactive commands. Lines beginning with # are ignored. 37 | // 38 | // The filename argument may begin with the special characters: 39 | // ~/ file is relative to the user's home directory. 40 | // !/ file is relative to the configuration directory. 41 | // 42 | // Returns non-nil error if the file could not be read. 43 | // 44 | func BatchLoad(filename string) error { 45 | batchError = false 46 | inBatchMode = true 47 | filename = pigpath.SubSpecialDirectories(filename) 48 | file, err := os.Open(filename) 49 | if err != nil { 50 | printBatchError(filename, err) 51 | inBatchMode = false 52 | return err 53 | } 54 | defer file.Close() 55 | raw, err := ioutil.ReadAll(file) 56 | if err != nil { 57 | printBatchError(filename, err) 58 | inBatchMode = false 59 | return err 60 | } 61 | fmt.Printf("Loading batch file '%s'\n", filename) 62 | lines := strings.Split(string(raw), "\n") 63 | for i, line := range lines { 64 | fmt.Printf("Batch [%3d] %s\n", i+1, line) 65 | piglog.Log(fmt.Sprintf("BATCH: [line %3d] %s", i, line)) 66 | command, args := parse(line) 67 | Eval(command, args) 68 | sleep(10) 69 | if batchError { 70 | break 71 | } 72 | } 73 | batchError = false 74 | inBatchMode = false 75 | return err 76 | } 77 | -------------------------------------------------------------------------------- /osc/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | ** osc package defines communication between Pigiron and external clients via OSC. 3 | ** 4 | ** There are two general OSC components: 5 | ** server - the pigiron application. 6 | ** client - application sending OSC message to pigiron. 7 | ** 8 | 9 | ** The client is represented within Pigiron by the Responder interface. In 10 | ** general for each OSC message received, a response message is sent back 11 | ** to the client. There are two types of response: 12 | ** 1) 'ACK', for Acknowledgment, responses indicates the received OSC message 13 | ** was processed without error. 14 | ** 2) 'ERROR' 15 | ** 16 | ** Both the ACK and Error responses include the original message address 17 | ** An ACK response may include requested data. 18 | ** 19 | */ 20 | 21 | package osc 22 | -------------------------------------------------------------------------------- /osc/osc.go: -------------------------------------------------------------------------------- 1 | package osc 2 | 3 | import ( 4 | "github.com/plewto/pigiron/config" 5 | "github.com/plewto/pigiron/pigpath" 6 | ) 7 | 8 | var ( 9 | globalResponder Responder 10 | replResponder Responder 11 | GlobalServer PigServer 12 | empty []string 13 | commands map[string]bool 14 | Exit bool = false // Flag to main-loop, if true exit application. 15 | ) 16 | 17 | 18 | // Init() initializes the osc package. 19 | // Init() must not be called prior to initialization of the config package. 20 | // 21 | func Init() { 22 | // Create global responders 23 | host := config.GlobalParameters.OSCClientHost 24 | port := int(config.GlobalParameters.OSCClientPort) 25 | root := config.GlobalParameters.OSCClientRoot 26 | filename := pigpath.SubSpecialDirectories(config.GlobalParameters.OSCClientFilename) 27 | globalResponder = NewBasicResponder(host, port, root, filename) 28 | replResponder = NewREPLResponder() 29 | commands = make(map[string]bool) 30 | // Create global OSC server 31 | host = config.GlobalParameters.OSCServerHost 32 | port = int(config.GlobalParameters.OSCServerPort) 33 | root = config.GlobalParameters.OSCServerRoot 34 | GlobalServer = NewServer(host, port, root) 35 | AddHandler(GlobalServer, "exec", remoteEval) 36 | } 37 | 38 | 39 | func isCommand(s string) bool { 40 | _, flag := commands[s] 41 | return flag || len(s) == 0 42 | } 43 | 44 | // Listen() starts OSC server. 45 | // 46 | func Listen() { 47 | GlobalServer.ListenAndServe() 48 | } 49 | 50 | // Cleanup() closes OSC server. 51 | // Cleanup should only be called on application termination. 52 | // 53 | func Cleanup() { 54 | GlobalServer.Close() 55 | } 56 | 57 | -------------------------------------------------------------------------------- /osc/repl.go: -------------------------------------------------------------------------------- 1 | package osc 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "os" 7 | "strings" 8 | "time" 9 | goosc "github.com/hypebeast/go-osc/osc" 10 | "github.com/plewto/pigiron/config" 11 | "github.com/plewto/pigiron/piglog" 12 | "github.com/plewto/pigiron/macro" 13 | ) 14 | 15 | const ( 16 | COMMENT = "#" 17 | ) 18 | 19 | var ( 20 | internalClient *goosc.Client 21 | reader *bufio.Reader 22 | // if true exit batch mode 23 | batchError bool = false 24 | 25 | // true while in batch mode. 26 | // OSC REPL client output suppressed while true. 27 | inBatchMode bool = false 28 | ) 29 | 30 | 31 | func init() { 32 | host := config.GlobalParameters.OSCServerHost 33 | port := int(config.GlobalParameters.OSCServerPort) 34 | internalClient = goosc.NewClient(host, port) 35 | reader = bufio.NewReader(os.Stdin) 36 | } 37 | 38 | 39 | func sleep(n int) { 40 | time.Sleep(time.Duration(n) * time.Millisecond) 41 | } 42 | 43 | // Prompt() displays terminal prompt. 44 | // 45 | func Prompt() { 46 | root := config.GlobalParameters.OSCServerRoot 47 | fmt.Printf("\n/%s: ", root) 48 | } 49 | 50 | 51 | func Read() string { 52 | s, _ := reader.ReadString('\n') 53 | return s 54 | } 55 | 56 | 57 | // splits command from arguments 58 | // 59 | func splitCommand(s string)(string, string) { 60 | s = strings.TrimSpace(s) 61 | pos := strings.Index(s, " ") 62 | if pos < 0 { 63 | return s, "" 64 | } 65 | command, args := s[:pos], strings.TrimSpace(s[pos:]) 66 | return command, args 67 | } 68 | 69 | 70 | func parse(s string)(string, []string) { 71 | s = strings.Split(s, COMMENT)[0] 72 | command, rawArgs := splitCommand(s) 73 | acc := make([]string, 0, len(rawArgs)) 74 | for _, a := range strings.Split(rawArgs, ",") { 75 | arg := strings.TrimSpace(a) 76 | acc = append(acc, arg) 77 | } 78 | return command, acc 79 | } 80 | 81 | // Eval() evaluates REPL commands. 82 | // 83 | func Eval(command string, args []string) { 84 | switch { 85 | case command == "": // ignore blank lines 86 | case command == "#": // ignore comment lines 87 | default: 88 | root := config.GlobalParameters.OSCServerRoot 89 | address := fmt.Sprintf("/%s/%s", root, command) 90 | msg := goosc.NewMessage(address) 91 | for _, s := range args { 92 | msg .Append(s) 93 | } 94 | internalClient.Send(msg) 95 | } 96 | } 97 | 98 | 99 | 100 | // REPL() enters the interactive command loop. 101 | // 102 | func REPL() { 103 | 104 | // Filter input characters as BUG 013 fix. 105 | // 106 | accept := func(s string) bool { 107 | for _, c := range strings.TrimSpace(s) { 108 | b := byte(c) 109 | if b < 0x20 || b > 0x7E { 110 | return false 111 | } 112 | } 113 | return true 114 | } 115 | 116 | for { 117 | fmt.Print(config.GlobalParameters.TextColor) 118 | Prompt() 119 | raw := strings.TrimLeft(Read(), " ,") 120 | if accept(raw) { 121 | piglog.Log(fmt.Sprintf("CMD : %s", raw)) 122 | command, args := parse(raw) 123 | filename, flag := batchFileExist(command) 124 | if flag { 125 | BatchLoad(filename) 126 | } else { 127 | if macro.IsMacro(command) { 128 | expanded, err := macro.Expand(command, args) 129 | if err != nil { 130 | fmt.Printf("ERROR: %v\n", err) 131 | continue 132 | } 133 | command, args = parse(expanded) 134 | } 135 | if isCommand(command) { 136 | Eval(command, args) 137 | } else { 138 | msg := fmt.Sprintf("ERROR: Invalid command: %s", command) 139 | fmt.Print(config.GlobalParameters.ErrorColor) 140 | fmt.Println(msg) 141 | fmt.Print(config.GlobalParameters.TextColor) 142 | } 143 | } 144 | } 145 | time.Sleep(10 * time.Millisecond) 146 | } 147 | } 148 | 149 | 150 | // /pig/eval command [,arg1, arg2, ...] 151 | // 152 | func remoteEval(msg *goosc.Message) ([]string, error) { 153 | var err error 154 | for _, raw := range msg.Arguments { 155 | command, args := parse(fmt.Sprintf("%v", raw)) 156 | command = strings.TrimRight(command, ",") 157 | Eval(command, args) 158 | time.Sleep(10 * time.Millisecond) 159 | } 160 | return empty, err 161 | } 162 | 163 | -------------------------------------------------------------------------------- /osc/responder.go: -------------------------------------------------------------------------------- 1 | package osc 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | goosc "github.com/hypebeast/go-osc/osc" 7 | "github.com/plewto/pigiron/config" 8 | "github.com/plewto/pigiron/piglog" 9 | 10 | ) 11 | 12 | // Responder interface defines how the OSC server sends responses back to the client. 13 | // 14 | // There are two possible responses: Ack and Error. 15 | // 16 | // Ack() sends an Acknowledgment that an OSC message has been received 17 | // without error. As part of its "payload" it includes the original 18 | // message and any requested data. 19 | // 20 | // An Error() response indicates the last received OSC message caused an 21 | // error. The response includes the offending OSC message and an additional 22 | // error message. 23 | // 24 | type Responder interface { 25 | Ack(sourceAddress string, args []string) 26 | Error(sourceAddress string, args []string, err error) 27 | String() string 28 | } 29 | 30 | // BasciResponder is the primary implementation of the Responder interface. 31 | // In addition to transmitting ACk and Error responses, it also 32 | // writes identical information to a temporary file. This is useful for 33 | // clients which do not receive OSC. The file is overwritten each time a 34 | // new OSC message is received. 35 | // 36 | type BasicResponder struct { 37 | client *goosc.Client 38 | root string 39 | filename string 40 | } 41 | 42 | 43 | // NewBasicResponder() creates a new instance of basicResponder. 44 | // 45 | func NewBasicResponder(ip string, port int, root string, filename string) Responder { 46 | client := goosc.NewClient(ip, port) 47 | responder := BasicResponder{client, root, filename} 48 | return &responder 49 | } 50 | 51 | // r.writeResponseFile() creates a file for the most recently transmitted message. 52 | // If the filename field is empty or it can not be created, the write is 53 | // silently ignored. 54 | // 55 | func (r *BasicResponder) writeResponseFile(sourceAddress string, payload string) { 56 | if len(r.filename) > 0 { 57 | file, err := os.Create(r.filename) 58 | if err == nil { 59 | defer file.Close() 60 | file.WriteString(fmt.Sprintf("%s\n", sourceAddress)) 61 | file.WriteString(payload) 62 | } 63 | } 64 | } 65 | 66 | // r.send() transmits OSC message to client. 67 | // 68 | func (r *BasicResponder) send(msg *goosc.Message) { 69 | r.client.Send(msg) 70 | } 71 | 72 | // f.Ack() transmits an Acknowledgment response to the client. 73 | // 74 | func (r *BasicResponder) Ack(sourceAddress string, args []string) { 75 | address := fmt.Sprintf("/%s/ACK", r.root) 76 | acc := fmt.Sprintf("ACK\n%s\n", sourceAddress) 77 | msg := goosc.NewMessage(address) 78 | msg.Append(sourceAddress) 79 | for i, a := range args { 80 | s := fmt.Sprintf("%s", a) 81 | msg.Append(s) 82 | acc += fmt.Sprintf("%s\n", s) 83 | piglog.Log(fmt.Sprintf("-> ACK [%3d] %s", i, a)) 84 | 85 | } 86 | r.send(msg) 87 | r.writeResponseFile(sourceAddress, acc) 88 | } 89 | 90 | // f.Error() transmits an Error response to the client. 91 | // 92 | func (r *BasicResponder) Error(sourceAddress string, args []string, err error) { 93 | address := fmt.Sprintf("/%s/ERROR", r.root) 94 | acc := fmt.Sprintf("ERROR\n%s\n", sourceAddress) 95 | msg := goosc.NewMessage(address) 96 | msg.Append(sourceAddress) 97 | msg.Append(fmt.Sprintf("%s\n", err)) 98 | acc += fmt.Sprintf("%s\n", err) 99 | piglog.Log(fmt.Sprintf("-> %s", acc)) 100 | for i, a := range args { 101 | s := fmt.Sprintf("%s", a) 102 | msg.Append(s) 103 | acc += fmt.Sprintf("%s\n", s) 104 | piglog.Log(fmt.Sprintf("-> ERR [%3d] %s", i, a)) 105 | } 106 | r.send(msg) 107 | r.writeResponseFile(sourceAddress, acc) 108 | } 109 | 110 | func (r *BasicResponder) String() string { 111 | host := r.client.IP() 112 | port := r.client.Port() 113 | root := r.root 114 | filename := r.filename 115 | acc := "BasicResponder " 116 | acc += fmt.Sprintf("root: %s, IP %s:%d, filename '%s'", root, host, port, filename) 117 | return acc 118 | } 119 | 120 | 121 | // REPLResponder struct is a Responder which prints messages to the terminal. 122 | // 123 | type REPLResponder struct {} 124 | 125 | // NewREPLResponder() creates new instance of REPLResponder. 126 | // 127 | func NewREPLResponder() Responder { 128 | return &REPLResponder{} 129 | } 130 | 131 | func setTextColor() { 132 | fmt.Print(config.GlobalParameters.TextColor) 133 | } 134 | 135 | func setErrorColor() { 136 | fmt.Print(config.GlobalParameters.ErrorColor) 137 | } 138 | 139 | func bar(text string) { 140 | fmt.Printf("\n-------------------------------- %s\n", text) 141 | } 142 | 143 | func (r *REPLResponder) Ack(sourceAddress string, args []string) { 144 | batchError = false 145 | if !inBatchMode { 146 | setTextColor() 147 | bar("OK") 148 | fmt.Println(sourceAddress) 149 | for i, a := range args { 150 | fmt.Printf("\t[%2d] %s\n", i, a) 151 | } 152 | Prompt() 153 | } 154 | } 155 | 156 | 157 | func (r *REPLResponder) Error(sourceAddress string, args []string, err error) { 158 | setErrorColor() 159 | bar("ERROR") 160 | fmt.Println(sourceAddress) 161 | fmt.Printf("%s\n", err) 162 | for i, a := range args { 163 | fmt.Printf("\t[%2d] %s\n", i, a) 164 | } 165 | setTextColor() 166 | Prompt() 167 | batchError = true 168 | } 169 | 170 | 171 | func (r *REPLResponder) String() string { 172 | return "REPLResponder" 173 | } 174 | 175 | -------------------------------------------------------------------------------- /osc/server.go: -------------------------------------------------------------------------------- 1 | package osc 2 | 3 | import ( 4 | "fmt" 5 | goosc "github.com/hypebeast/go-osc/osc" 6 | "github.com/plewto/pigiron/piglog" 7 | ) 8 | 9 | 10 | // PigServer interface defines server-side OSC interface. 11 | // 12 | // Root() string 13 | // Returns the OSC address prefix, default is '/pig' 14 | // 15 | // SetRoot(s string) 16 | // Sets OSC server address prefix. 17 | // 18 | // GetResponder() Responder 19 | // Returns the OSC responder. 20 | // 21 | // GetREPLResponder() Responder 22 | // Returns the responder used for printing to the terminal. 23 | // 24 | // AddMsgHandler(address string, handler func(msg *go-osc.Message)) 25 | // Adds an OSC handler function. 26 | // 27 | // ListenAndServe() 28 | // Start server. 29 | // 30 | // IP() string 31 | // Returns server IP address. 32 | // 33 | // Port() int 34 | // Returns server port number. 35 | // 36 | // Close() 37 | // Close the server. Close should only be called on termination of the 38 | // program. 39 | // 40 | // Commands() []string 41 | // Returns list of defined OSC commands. 42 | // 43 | type PigServer interface { 44 | Root() string 45 | SetRoot(string) 46 | GetResponder() Responder 47 | GetREPLResponder() Responder 48 | AddMsgHandler(address string, handler func(msg *goosc.Message)) 49 | ListenAndServe() 50 | IP() string 51 | Port() int 52 | Close() 53 | Commands() []string 54 | } 55 | 56 | 57 | // OSCServer struct implements the PigServer interface. 58 | // 59 | type OSCServer struct { 60 | backingServer *goosc.Server 61 | dispatcher *goosc.StandardDispatcher 62 | root string 63 | responder Responder 64 | replResponder Responder 65 | ip string 66 | port int 67 | commands []string 68 | } 69 | 70 | 71 | // NewServer() returns a new PigServer. 72 | // 73 | func NewServer(ip string, port int, root string) PigServer { 74 | server := new(OSCServer) 75 | server.ip = ip 76 | server.port = port 77 | server.root = root 78 | server.responder = globalResponder 79 | server.replResponder = replResponder 80 | addr := fmt.Sprintf("%s:%d", ip, port) 81 | server.dispatcher = goosc.NewStandardDispatcher() 82 | server.backingServer = &goosc.Server { 83 | Addr: addr, 84 | Dispatcher: server.dispatcher, 85 | } 86 | server.commands = make([]string, 0, 16) 87 | return server 88 | } 89 | 90 | func (s *OSCServer) GetResponder() Responder { 91 | return s.responder 92 | } 93 | 94 | func (s *OSCServer) GetREPLResponder() Responder { 95 | return s.replResponder 96 | } 97 | 98 | func (s *OSCServer) AddMsgHandler(address string, handler func(msg *goosc.Message)) { 99 | 100 | logger := func(msg *goosc.Message) { 101 | piglog.Print(msg.Address) 102 | for i, a := range msg.Arguments { 103 | piglog.Print(fmt.Sprintf("[%2d] %v", i, a)) 104 | } 105 | handler(msg) 106 | } 107 | s.dispatcher.AddMsgHandler(address, logger) 108 | s.commands = append(s.commands, address) 109 | } 110 | 111 | func (s *OSCServer) Commands() []string { 112 | return s.commands 113 | } 114 | 115 | func (s *OSCServer) ListenAndServe() { 116 | fmt.Printf("OSC Listening: %s:%d /%s\n", s.ip, s.port, s.root) 117 | go s.backingServer.ListenAndServe() 118 | } 119 | 120 | func (s *OSCServer) Root() string { 121 | return s.root 122 | } 123 | 124 | func (s *OSCServer) SetRoot(root string) { 125 | s.root = root 126 | } 127 | 128 | 129 | func (s *OSCServer) IP() string { 130 | return s.ip 131 | } 132 | 133 | func (s *OSCServer) Port() int { 134 | return s.port 135 | } 136 | 137 | func (s *OSCServer) Close() { 138 | s.backingServer.CloseConnection() 139 | } 140 | 141 | // AddHandler() adds new OSC handler function to server s. 142 | // The OSC address prefix is automatically added to the command argument. 143 | // The command "foo" --> becomes "/pig/foo" 144 | // 145 | func AddHandler(s PigServer, command string, handler func(*goosc.Message)([]string, error)) { 146 | commands[command] = true 147 | address := fmt.Sprintf("/%s/%s", s.Root(), command) 148 | var result = func(msg *goosc.Message) { 149 | status, err := handler(msg) 150 | if err != nil { 151 | s.GetResponder().Error(address, status, err) 152 | s.GetREPLResponder().Error(address, status, err) 153 | } else { 154 | s.GetResponder().Ack(address, status) 155 | s.GetREPLResponder().Ack(address, status) 156 | } 157 | } 158 | s.AddMsgHandler(address, result) 159 | } 160 | -------------------------------------------------------------------------------- /pigerr/pigerror.go: -------------------------------------------------------------------------------- 1 | package pigerr 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | // Error returns new error object with given message. 8 | // 9 | func New(message string, more ...string) error { 10 | msg := fmt.Sprintf("ERROR: %s", message) 11 | for _, s := range more { 12 | msg += fmt.Sprintf("\nERROR: %s", s) 13 | } 14 | return fmt.Errorf(msg) 15 | } 16 | 17 | // CompoundError creates new error by composing previous error with new message. 18 | // 19 | func CompoundError(err1 error, message string, more ...string) error { 20 | msg := fmt.Sprintf("ERROR: %s\n", message) 21 | for _, s := range more { 22 | msg += fmt.Sprintf("\nERROR: %s", s) 23 | } 24 | msg += fmt.Sprintf("PREVIOUS ERROR:\n%s", err1) 25 | return fmt.Errorf(msg) 26 | } 27 | 28 | 29 | type warning interface { 30 | String() string 31 | } 32 | 33 | type baseWarning struct { 34 | message string 35 | } 36 | 37 | func (w *baseWarning) String() string { 38 | return w.message 39 | } 40 | 41 | 42 | func Warning(message string, more ...string) warning { 43 | msg := fmt.Sprintf("\nWARNING: %s", message) 44 | for _, s := range more { 45 | msg += fmt.Sprintf("\nWARNING: %s", s) 46 | } 47 | warn := baseWarning{msg} 48 | fmt.Printf("%s\n", warn.String()) 49 | return &warn 50 | } 51 | 52 | 53 | -------------------------------------------------------------------------------- /piglog/piglog.go: -------------------------------------------------------------------------------- 1 | package piglog 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "log" 7 | "github.com/plewto/pigiron/config" 8 | "github.com/plewto/pigiron/pigpath" 9 | ) 10 | 11 | var ( 12 | logfile string 13 | file *os.File 14 | ) 15 | 16 | 17 | func init() { 18 | if config.GlobalParameters.EnableLogging { 19 | logfile = pigpath.SubSpecialDirectories(config.GlobalParameters.Logfile) 20 | var err error 21 | _ = os.Remove(logfile) 22 | file, err = os.OpenFile(logfile, os.O_WRONLY | os.O_CREATE, 0666) 23 | 24 | if err != nil { 25 | errmsg := "ERROR: Can not open log file: %s, logging disabled\n\n" 26 | fmt.Printf(errmsg, logfile) 27 | config.GlobalParameters.EnableLogging = false 28 | } else { 29 | log.SetOutput(file) 30 | } 31 | } 32 | } 33 | 34 | 35 | func Logfile() string { 36 | return logfile 37 | } 38 | 39 | func Close() { 40 | if file != nil { 41 | fmt.Printf("Closing logfile: %s\n", logfile) 42 | file.Close() 43 | } 44 | } 45 | 46 | 47 | func Log(text ...string) { 48 | if config.GlobalParameters.EnableLogging { 49 | for _, s := range text { 50 | log.Print(s) 51 | } 52 | } 53 | } 54 | 55 | func Print(s string) { 56 | if config.GlobalParameters.EnableLogging { 57 | log.Print(s) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /pigpath/pigpath.go: -------------------------------------------------------------------------------- 1 | /* 2 | ** pigpath package provides utilities for for making filename substitutions. 3 | ** Specifically leading characters '~' and '!' are replaced with the user's 4 | ** home and pigiron configuration directories respectively. 5 | ** 6 | */ 7 | 8 | package pigpath 9 | 10 | import ( 11 | "os" 12 | "path/filepath" 13 | ) 14 | 15 | func UserHomeDir() string { 16 | dir, _ := os.UserHomeDir() 17 | return dir 18 | } 19 | 20 | func PigironConfigDir() string { 21 | dir, _ := os.UserConfigDir() 22 | return filepath.Join(dir, "pigiron") 23 | } 24 | 25 | 26 | // ResourceFilename returns filename relative to the resources directory. 27 | // The resources directory is located at /resources/ 28 | // On Linux this location is ~/.config/pigiron/resources/ 29 | // 30 | // Example: 31 | // ResourceFilename("foo", "bar.txt") --> ~/.config/pigiron/resources/foo/bar.txt 32 | // 33 | func ResourceFilename(elements ...string) string { 34 | acc := filepath.Join(PigironConfigDir(), "resources") 35 | for _, e := range elements { 36 | acc = filepath.Join(acc, e) 37 | } 38 | return acc 39 | } 40 | 41 | 42 | // SubSpecialDirectories replaces leading ~ and ! characters in filename 43 | // '~' is replaced with user's home dir. 44 | // '!' is replaced with pigiron configuration dir. 45 | // 46 | func SubSpecialDirectories (filename string) string { 47 | result := filename 48 | if len(filename) > 0 { 49 | switch { 50 | case filename[0] == '~': 51 | home := UserHomeDir() 52 | result = filepath.Join(home, filename[1:]) 53 | case filename[0] == '!': 54 | cnfig := PigironConfigDir() 55 | result = filepath.Join(cnfig, filename[1:]) 56 | default: 57 | // ignore 58 | } 59 | } 60 | return result 61 | } 62 | 63 | 64 | // Join concatenates filepath components into string. 65 | // It is like the standard filepath.Join but substitutes leading 66 | // ~ and ! characters with user's home and pigiron configuration 67 | // directories respectively. 68 | // 69 | func Join(base string, elements ...string) string { 70 | var acc = make([]string,1,12) 71 | acc[0] = SubSpecialDirectories(base) 72 | for _, e := range elements { 73 | acc = append(acc, e) 74 | } 75 | return filepath.Join(acc...) 76 | } 77 | 78 | -------------------------------------------------------------------------------- /pigpath/pigpath_test.go: -------------------------------------------------------------------------------- 1 | package pigpath 2 | 3 | import ( 4 | "testing" 5 | "fmt" 6 | "path/filepath" 7 | ) 8 | 9 | 10 | func TestPigpath(t *testing.T) { 11 | fmt.Print("") 12 | 13 | home := UserHomeDir() 14 | config := PigironConfigDir() 15 | 16 | a := "~/foo" 17 | b := SubSpecialDirectories(a) 18 | if b[0:len(home)] != home { 19 | t.Fatalf("Expected filename start to be user's home: got '%s'", b) 20 | } 21 | 22 | a = "!/foo" 23 | b = SubSpecialDirectories(a) 24 | 25 | if b[0:len(config)] != config { 26 | t.Fatalf("Expected filename start to be configuration directory, got '%s'", b) 27 | } 28 | 29 | b = Join("~", "alpha", "bar") 30 | expect := filepath.Join(home, "alpha", "bar") 31 | if b != expect { 32 | t.Fatalf("Expected filenem.Join to return '%s', got '%s'", expect, b) 33 | } 34 | 35 | result := Join("~") 36 | if result != home { 37 | t.Fatalf("Expected Join(\"~\") to return '%s', got '%s'\n", home, result) 38 | } 39 | 40 | result = Join("!") 41 | if result != config { 42 | t.Fatalf("Expected Join(\"~\") to return '%s', got '%s'\n", config, result) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /resources/batch/example: -------------------------------------------------------------------------------- 1 | # Example batch file creates the following process tree. 2 | # 3 | # MIDIInput -> ChannelFilter -> Distributor -+-> Monitor -> MIDIOutput 4 | # | 5 | # MIDIPlayer ---+ 6 | # 7 | # Commands have the general form: 8 | # 9 | # command arg-1, arg-2, ..., arg-n 10 | # 11 | # The command 'q-commands' displays a list of available commands, 12 | # while the help command displays help text for a specific command. 13 | # 'help topics' displays a list of available topics. 14 | # 15 | # 16 | # The 'new' command creates new operators. 17 | # 18 | # syntax: new operator-type, name 19 | # 20 | # 21 | # An Operator's name must be unique. A name may be reused only for the 22 | # same type of operator: 23 | # 24 | # new ChannelFilter, foo 25 | # new ChannelFilter, foo 26 | # 27 | # The above lines are OK, however the 2nd line is ignored and the original 28 | # 'foo' operator remains in place. 29 | # 30 | # new ChannelFilter, foo 31 | # new Distribute, foo 32 | # 33 | # These two lines will fail. The original 'foo' operator will remain the 34 | # same but a Distributor with the same name can not be created. 35 | 36 | new ChannelFilter, filter # creates a ChannelFilter named 'filter'. 37 | 38 | 39 | # For the MIDIInput and MIDIOutput Operators the new command has a slightly 40 | # different syntax. 41 | # 42 | # new MIDIInput, name, device 43 | # new MIDIOutput, name, device 44 | # 45 | # Where device is the backing MIDI device and may either be an integer 46 | # index or a sub-string of the device's name. 47 | # 48 | # The 'q-midi-inputs' and 'q-midi-outputs' commands display a list of 49 | # available MIDI devices. On the system this is being written on 50 | # q-midi-inputs returns: 51 | # 52 | # [ 0] "Midi Through Port-0" 53 | # [ 1] "E-MU Xmidi 2x2 MIDI 1" 54 | # [ 2] "E-MU Xmidi 2x2 MIDI 2" 55 | # [ 3] "Arturia MiniLab mkII MIDI 1" 56 | # [ 4] "FastTrack Pro MIDI 1" 57 | # 58 | # The device at index 1 may be specified by either the number 1 or by a 59 | # sub-string of its name. If the sub-string matches more then 60 | # one device, the first matching device is used. 61 | # 62 | 63 | new MIDIInput, in, 0 # device by index 64 | new MIDIOutput, out, 0 65 | 66 | 67 | # Create remaining Operators 68 | # 69 | 70 | new MIDIPlayer, player 71 | new Distributor, distributor 72 | new Monitor, monitor 73 | 74 | # The connect command makes non-branching connections between operators. At 75 | # a minimum it takes two operator arguments, but may make any number of 76 | # additional connections: 77 | # 78 | # connect a, b, c, ... # a -> b -> c -> ... 79 | # 80 | 81 | connect in, filter, distributor, monitor, out 82 | 83 | # Since the MIDI player is on a branch, a separate connect command is 84 | # required. 85 | 86 | connect player, monitor 87 | 88 | 89 | # The 'op' command is used for sub-commands of an operator, the general 90 | # form is 91 | # 92 | # op name, sub-command [,arguments ....] 93 | # 94 | # 'help OperatorType' will display list of sub-commands for a specific 95 | # Operator type. The MIDIPlayer operator has the following three 96 | # sub-commands (among others). 97 | # 98 | # op player, stop 99 | # op player, play 100 | # op player, load, filename 101 | # 102 | # Macros may be used to simplify commonly used commands. 103 | # 104 | 105 | macro stop, player, stop 106 | macro play, player, play 107 | macro load, player, load, $0 108 | 109 | # Hereafter 'stop', 'play' and 'load' may be used instead of the more 110 | # cumbersome op with sub-command form. The $0 term in the load macro 111 | # substitutes the first macro argument into the expanded form. 112 | # 113 | # load /foo/bar/baz 114 | # 115 | # Expands to 116 | # 117 | # op player, load, /foo/bar/baz 118 | # 119 | 120 | 121 | # Some Useful commands. 122 | # 123 | # q-operator-types - returns list of available operator types. 124 | # q-operators - returns list of all operators. 125 | # q-roots - returns list of all root operators. 126 | # print-graph - prints structure of the MIDI process graph. 127 | # info name - displays current state of Operator name. 128 | 129 | 130 | # The following commands relate to MIDI channel selections. 131 | # 132 | # q-channel-mode operator-name 133 | # Returns either NoChannel, SingleChannel or MultiChannel. 134 | # 135 | # 136 | # q-channels operator-name 137 | # Returns list of selected channels. 138 | # 139 | # 140 | # q-channel-selected operator-name, channel 141 | # Returns true if channel is enabled, false otherwise. 142 | # 143 | # 144 | # select-channels operator-name, chan-A, chan-B, .... 145 | # Selects all listed channels as enabled. 146 | # For Operators with SingleChannel mode, the final channel is selected. 147 | # 148 | # 149 | # deselect-channels operator-name, chan-A, chan-B, ... 150 | # Disables all selected channels. 151 | # Ignored by Operators with SingleChannelMode. 152 | # 153 | # 154 | # select-all-channels operator-name 155 | # Ignored by Operators with SingleChannelMode (see BUG 008). 156 | # 157 | # 158 | # deselect-all-channels operator-name 159 | # Ignored by Operators with SingleChannelMode. 160 | # 161 | # 162 | # invert-channels operator-name 163 | # Ignored by Operators with SingleChannelMode. 164 | 165 | -------------------------------------------------------------------------------- /resources/batch/example-2: -------------------------------------------------------------------------------- 1 | # example-2 Pigiron batch file. 2 | # Chord generator in key of C 3 | # 4 | # MidiInput -> ChannelFilter --+--> Transposer --+--> MIDIOutput 5 | # +--> Transposer --+ 6 | # | | 7 | # +-----------------+ 8 | # 9 | 10 | 11 | new MIDIInput, in, Arturia # Replace device names as required. 12 | new MIDIOutput, out, MIDI 1 # Replace device names as required. 13 | new ChannelFilter, filter 14 | new Transposer, fifths 15 | new Transposer, thirds 16 | new Monitor, mon 17 | 18 | connect in, filter, fifths, out, mon 19 | connect filter, thirds, out 20 | connect filter, out 21 | 22 | select-all-channels filter 23 | 24 | 25 | # Transposer fifths transposes all notes up a fifth (+7) 26 | # The first 0 is the starting index of where to write the remaining values 27 | # into the table. 28 | # 29 | op fifths, set-table-value, 0, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,116,117,118,119,120,121,122 30 | 31 | # Transposer thirds transposes notes selectively 32 | # For keys C, F and G, transpose up a major-third (+4) 33 | # For all other white keys, transpose up a minor-third (+3) 34 | # For black keys, transpose down a minor-third (-3) 35 | # 36 | op thirds, set-table-value, 0, 4, 10, 5, 0, 7, 9, 3, 11, 5, 12, 7, 14, 16, 10, 17, 12, 19, 21, 15, 23, 17, 24, 19, 26, 28, 22, 29, 24, 31, 33, 27, 35, 29, 36, 31, 38, 40, 34, 41, 36, 43, 45, 39, 47, 41, 48, 43, 50, 52, 46, 53, 48, 55, 57, 51, 59, 53, 60, 55, 62, 64, 58, 65, 60, 67, 69, 63, 71, 65, 72, 67, 74, 76, 70, 77, 72, 79, 81, 75, 83, 77, 84, 79, 86, 88, 82, 89, 84, 91, 93, 87, 95, 89, 96, 91, 98,100, 94,101, 96,103,105, 99,107,101,108,103,110,112,106,113,108,115,117,111,119,113,120,115,122,124,118,125,120,127,117,123,119 37 | 38 | 39 | # Modify thirds table starting at index 60 so that for the octave 40 | # starting at middle C, staked fifths are produced (C, G, D) 41 | op thirds, set-table-value, 60, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86 -------------------------------------------------------------------------------- /resources/config.toml: -------------------------------------------------------------------------------- 1 | # Pigiron configuration 2 | # !/ --> alias for config directory 3 | # ~/ --> alias for user home 4 | # 5 | 6 | [log] 7 | enable = false 8 | logfile = "" 9 | 10 | [batch] 11 | directory = "!/batch" 12 | 13 | [osc-server] 14 | root = "pig" 15 | host = "127.0.0.1" 16 | port = 8020 17 | 18 | [osc-client] 19 | root = "pig-client" 20 | host = "127.0.0.1" 21 | port = 8021 22 | file = "~/.config/pigiron/response" 23 | 24 | [tree] 25 | max-depth = 12 26 | 27 | [midi-input] 28 | buffer-size = 1024 29 | poll-interval = 8 # in ms, may be 0 30 | 31 | [midi-output] 32 | buffer-size = 1024 33 | latency = 0 34 | 35 | 36 | # Named terminal colors may be: 37 | # red, green, yellow, blue, purple, cyan, gray and white 38 | # Terminal colors are not avaliable on Windows. 39 | 40 | [colors] 41 | banner = "green" 42 | text = "gray" 43 | error = "yellow" 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /resources/help/ChannelFilter: -------------------------------------------------------------------------------- 1 | Operator ChannelFilter 2 | 3 | 4 | A ChannelFilter is an Operator which selectively blocks/passes MIDI 5 | messages based on channel. Any number of channels may be selected 6 | simultaneously. Separately non-channel messages my be blocked. 7 | 8 | Use the more efficient SingleChannelFilter for single MIDI channel 9 | selection. 10 | 11 | Sub-Commands: 12 | 13 | Command op name, enable-system-events, bool 14 | OSC /pig/op name, enable-system-events, bool 15 | 16 | Enables/disables filtering of MIDI system messages for named ChannelFilter 17 | 18 | OSC Return: AKC 19 | 20 | ----------------------------------------------------- 21 | 22 | Command op name, q-system-events-enabled 23 | OSC /pig/op name, q-system-events-enabled 24 | 25 | OSC Return: AKC bool false if MIDI system messages are filtered. 26 | 27 | -------------------------------------------------------------------------------- /resources/help/Distributor: -------------------------------------------------------------------------------- 1 | Operator Distributor 2 | 3 | A Distributor is an Operator which re-channelizes incoming MIDI messages. 4 | 5 | Multiple output channels may be selected simultaneously. The message 6 | (regardless of it's original channel) is re-broadcast on each of the 7 | selected output channels. Non-channel messages are always passed 8 | unaltered. 9 | 10 | 11 | Sub-Commands: 12 | 13 | None -------------------------------------------------------------------------------- /resources/help/MIDIInput: -------------------------------------------------------------------------------- 1 | Operator MIDIInput 2 | 3 | MIDIInput is an Operator wrapper for a MIDI Input device. 4 | 5 | There may only be a single MIDIInput operator for a given MIDI device. 6 | If an attempt is made to create a MIDIInput for a device already in use, 7 | the original MIDIInput operator is returned. Once created a MIDIInput 8 | can not be deleted. 9 | 10 | 11 | Sub-Commands 12 | 13 | Command op name, q-device 14 | OSC /pig/op name, q-device 15 | 16 | Gets name of MIDI device. 17 | 18 | OSC Return: ACK: MIDI device name. 19 | 20 | -------------------------------------------------------------------------------- /resources/help/MIDIOutput: -------------------------------------------------------------------------------- 1 | Operator MIDIOutput 2 | 3 | MIDIOutput is an Operator wrapper for a MIDI Output device. 4 | 5 | There may only be a single MIDIOutput operator for a given MIDI device. If 6 | an attempt is made to create a MIDIOutput for a device already in use, the 7 | original MIDIOutput operator is returned. 8 | 9 | Sub-Commands 10 | ------------------------------------------------------------ 11 | Command op name, q-device 12 | OSC /pig/op name, q-device 13 | 14 | Gets name of MIDI device. 15 | 16 | OSC Return: ACK: MIDI device name. 17 | 18 | ------------------------------------------------------------ 19 | Command op name, q-off-velocity 20 | OSC /pig/op name, q-off-velocity 21 | 22 | Returns true if NOTE_OFF velocity is enabled. 23 | 24 | ------------------------------------------------------------ 25 | Command op name, enable-off-velocity, bool 26 | OSC /pig/op name, enable-off-velocity, bool 27 | 28 | Enables/disables NOTE_OFF velocity. If disabled all off velocities are set 29 | to 0. 30 | -------------------------------------------------------------------------------- /resources/help/MIDIPlayer: -------------------------------------------------------------------------------- 1 | Operator MIDIPlayer 2 | 3 | MIDIPlayer is an Operator for playing MIDI Files. 4 | 5 | 6 | Currently only MIDI file format 0 (single track) is supported. For format 1 7 | (multi-track) files, only the first track is played. Format 2 (multi-song) 8 | files are rare and not support. 9 | 10 | Sub-Commands 11 | 12 | ------------------------------------------------------------ 13 | Command op name, stop 14 | OSC /pig/op name, stop 15 | 16 | Halts playback. 17 | 18 | OSC Return: ACK 19 | 20 | ------------------------------------------------------------ 21 | 22 | Command op name, play 23 | OSC /pig/op name, play 24 | 25 | Starts playback from beginning. 26 | 27 | OSC Return: ACK 28 | ERROR if no MIDI file has been loaded. 29 | 30 | ------------------------------------------------------------ 31 | 32 | 33 | Command op name, continue 34 | OSC /pig/op name, continue 35 | 36 | Continue playback from current location. 37 | 38 | OSC Return: ACK 39 | ERROR if no MIDI file has been loaded. 40 | 41 | ------------------------------------------------------------ 42 | 43 | Command op name, load, filename 44 | OSC /pig/op name, load, filename 45 | 46 | Load named MIDI file. 47 | Filename may be prefixed with ~/ for home directory or !/ for 48 | configuration directory. 49 | 50 | OSC Return: ACK the absolute filename. 51 | ERROR if file could not be read as a MIDI file. 52 | 53 | ------------------------------------------------------------ 54 | 55 | Command op name, enable-midi-transport, bool 56 | OSC /pig/op name, enable-midi-transport, bool 57 | 58 | Sets whether the player responds to MIDI stop, play and continue messages. 59 | 60 | OSC Return: ACK 61 | ERROR if bool is not valid Boolean value. 62 | 63 | ------------------------------------------------------------ 64 | 65 | Command op name, q-midi-transport-enabled 66 | OSC /pig/op name, q-midi-transport-enabled 67 | 68 | Checks if MIDI transport mode is enabled. 69 | 70 | OSC Return: ACK bool 71 | 72 | ------------------------------------------------------------ 73 | 74 | Command op name, q-is-playing 75 | OSC /pig/op name, q-is-playing 76 | 77 | Checks if playback is currently in progress. 78 | 79 | OSC Return: ACK bool. 80 | 81 | ------------------------------------------------------------ 82 | 83 | Command op name, q-duration 84 | OSC /pig/op name, q-duration 85 | 86 | 87 | 88 | Gets approximate media duration in seconds. 89 | OSC Return: ACK duration in seconds. 90 | 91 | ------------------------------------------------------------ 92 | 93 | Command op name, q-position 94 | OSC /pig/op name, q-position 95 | 96 | Gets current playback position in seconds. 97 | 98 | OSC Return: ACK position in seconds. 99 | 100 | ------------------------------------------------------------ 101 | 102 | Command op name, q-media-filename 103 | OSC /pig/op name, q-media-filename 104 | 105 | Gets absolute filename for MIDI file 106 | 107 | OSC Return: ACK filename. 108 | 109 | -------------------------------------------------------------------------------- /resources/help/Monitor: -------------------------------------------------------------------------------- 1 | Operator type Monitor 2 | 3 | A Monitor is an Operator which displays MIDI traffic to the terminal. 4 | BY default all MIDI messages are displayed. 5 | 6 | Messages may be filtered by MIDI channel and/or status byte. 7 | 8 | Resetting a Monitor removes all filters. 9 | 10 | Events may optionally be saved to a logfile. 11 | 12 | 13 | Sub-Commands: 14 | ------------------------------------------------------------ 15 | Command op name, q-excluded-status 16 | OSC /pig/op name, q-excluded-status 17 | 18 | Returns list of excluded MIDI status bytes. MIDI events with matching 19 | status bytes are ignored. 20 | 21 | ------------------------------------------------------------ 22 | Command op name, exclude-status, st, flag 23 | OSC /pig/op name, exclude-status, st, flag 24 | 25 | If flag is true, adds MIDI status byte st to the exclude-list. 26 | 27 | ------------------------------------------------------------ 28 | Command op name, q-enabled 29 | OSC /pig/op name, q-enabled 30 | 31 | Returns true if monitor is enabled. 32 | 33 | ------------------------------------------------------------ 34 | Command op name, enable, flag 35 | OSC /pig/op name, enable, flag 36 | 37 | Enables/disables monitor 38 | 39 | 40 | ------------------------------------------------------------ 41 | Command op name, open-logfile, filename 42 | OSC /pig/op name, open-logfile, filename 43 | 44 | Opens log file 45 | Returns true name for log file. 46 | 47 | ------------------------------------------------------------ 48 | Command op name, close-logfile 49 | OSC /pig/op name, close-logfile 50 | 51 | Closes log file. 52 | The log file is closed automatically when the Monitor is deleted or 53 | Pigiron exits. 54 | 55 | 56 | ------------------------------------------------------------ 57 | Command op name, q-logfile 58 | OSC /pig/op name, q-logfile 59 | 60 | Returns log filename. 61 | Returns '' if logfile is closed. 62 | -------------------------------------------------------------------------------- /resources/help/OSC: -------------------------------------------------------------------------------- 1 | Pigiron OSC 2 | 3 | For the purpose of OSC communication Pigiron functions as a server. 4 | Applications sending OSC messages to Pigiron are the clients. Important 5 | values for the server and client are established at startup by the 6 | configuration file and can not be changed. 7 | 8 | The command line REPL and batch file processing constitutes an additional 9 | internal client. 10 | 11 | When the server receives an OSC message, it is dispatched to a handler 12 | function for that specific message. The result of the handler is then sent 13 | back to -all- clients. 14 | 15 | There are two general responses: 16 | 17 | 1. ACK (acknowledge) - the message was handled without error. 18 | 2. ERROR 19 | 20 | Both ACK and ERROR responses include a list of strings. The first element 21 | of the list is the OSC address of the original message. The remaining 22 | elements for an ACK response are any requested values. It is up to the 23 | client to convert numeric and Boolean values from strings to the 24 | appropriate types. For an ERROR response the remaining values are the error 25 | message. 26 | 27 | 28 | If the configuration value OSCClientFilename is a non-empty string, the 29 | contents of the OSC response is sent to that file. The file is 30 | overwritten with each received message. This file allows clients which 31 | transmit, but not receive, OSC messages to check the response to a 32 | message. Sending the responses to this temp file is in parallel to 33 | transmitting the OSC response. 34 | 35 | 36 | By default the server's OSC addresses have the prefix /pig. The default 37 | client prefix is /pig-client. The message /pig/ping is a diagnostic used 38 | to test communication between the client and server. The following 39 | sequence of events should occur when the client transmits /pig/ping. 40 | 41 | 42 | 1. client sends OSC message /pig/ping 43 | 2. server responds with the OSC message /pig-client/ACK, /pig/ping 44 | 3. server writes similar content to the temp response file. 45 | 4. server sends a response to the internal client and it prints it to the 46 | terminal. 47 | 48 | An identical sequence of events occurs when the original message comes from 49 | the command line or a batch file. The only difference is that for 50 | internal messages the '/pig' prefix is automatically added, the prefix 51 | should not be included when manually entering commands are in a batch 52 | file. 53 | 54 | If a received message produces an error, the same sequence of responses 55 | occurs except the ACK message is replaced with ERROR. 56 | -------------------------------------------------------------------------------- /resources/help/SingleChannelFilter: -------------------------------------------------------------------------------- 1 | Operator SingleChannelFilter 2 | 3 | 4 | A SingleChannelFilter is an Operator which selectively blocks/passes MIDI 5 | messages based on channel. Only a single channel may be selected at any time. 6 | Separately non-channel messages my be blocked. Use ChannelFilter for 7 | multi-channel filtering. 8 | 9 | 10 | Sub-Commands: 11 | 12 | Command op name, enable-system-events, bool 13 | OSC /pig/op name, enable-system-events, bool 14 | 15 | Enables/disables filtering of MIDI system messages for named SingleChannelFilter 16 | 17 | OSC Return: AKC 18 | 19 | ----------------------------------------------------- 20 | 21 | Command op name, q-system-events-enabled 22 | OSC /pig/op name, q-system-events-enabled 23 | 24 | OSC Return: AKC bool false if MIDI system messages are filtered. 25 | -------------------------------------------------------------------------------- /resources/help/Transformer: -------------------------------------------------------------------------------- 1 | Operator Transformer 2 | 3 | Transformer is an Operator which selectively modifies MIDI data bytes. 4 | Either data byte of any channel message may be modified. 5 | 6 | General parameters: 7 | transpose table - The transformation values tab(q) --> r, where 0x00 <= q, r < 0x80. 8 | status - The MIDI status type to be modified. Possible values are: 9 | 0x00 - Disabled 10 | 0x01 - both KEY_OFF & KEY_ON 11 | 0x80 - NOTE_OFF 12 | 0x90 - NOTE_ON 13 | 0xA0 - POLY_PRESSURE 14 | 0xB0 - CONTROLLER 15 | 0xC0 - PROGRAM 16 | 0xD0 - CHANNEL_PRESSURE 17 | 0xE0 - BEND 18 | dataNumber - the data byte to be modified, either 1 or 2 19 | 20 | ------------------------------------------------------------ 21 | Command op name, q-table-range 22 | OSC /pig/op name, q-table-range 23 | 24 | OSC Returns: 25 | [1] floor - minimum table index 26 | [2] ceiling - maximum table index 27 | 28 | Table indexes must satisfy floor <= index < ceiling. 29 | 30 | ------------------------------------------------------------ 31 | Command op name, q-table-value, index 32 | OSC /pig/op name, q-table-value, index 33 | 34 | OSC Returns: 35 | Table value at indicated index. 36 | 37 | ------------------------------------------------------------ 38 | Command op name, set-table-value, index, value-1 [,value-2, value-3, ...] 39 | OSC /pig/op name, set-table-value, index, value-1 [,value-2, value-3, ...] 40 | 41 | Sets table value(s) starting at index. 42 | 43 | ------------------------------------------------------------ 44 | Command op name, print-table 45 | OSC /pig/op name, print-table 46 | 47 | Prints hex-dump of transform table. 48 | 49 | ------------------------------------------------------------ 50 | Command op name, select-status, status 51 | OSC /pig/op name, select-status, status 52 | 53 | Selects which MIDI message type are modified. 54 | status may be one of the following: 55 | 0x00 - DISABLE 56 | 0x01 - KEY_OFF & KEY_ON 57 | 0x80 - KEY_OFF 58 | 0x90 - KEY_ON 59 | 0xA0 - POLY_PRESSURE 60 | 0xB0 - CONTROLLER 61 | 0xC0 - PROGRAM 62 | 0xD0 - MONO_PRESSURE 63 | 0xE0 - PITCH_BEND 64 | 65 | ------------------------------------------------------------ 66 | Command op name, q-status 67 | OSC /pig/op name, q-status 68 | 69 | OSC Returns: 70 | selected status, See select-status to interpret results. 71 | 72 | ------------------------------------------------------------ 73 | Command op name, select-data-byte, n 74 | OSC /pig/op name, select-data-byte, n 75 | 76 | Selects which MIDI data byte is to be modified. 77 | Valid values for n are 1 and 2. 78 | 79 | ------------------------------------------------------------ 80 | Command op name, q-data-byte 81 | OSC /pig/op name, q-data-byte 82 | 83 | OSC Returns number of data byte being modified, either 1 or 2. 84 | 85 | 86 | -------------------------------------------------------------------------------- /resources/help/batch: -------------------------------------------------------------------------------- 1 | Command batch filename 2 | OSC /pig/batch filename 3 | 4 | Loads filename as an OSC batch file. 5 | Within the file commands are listed in sequence. 6 | Lines beginning with # are ignored. 7 | 8 | The filename prefix may indicate one of two special directories: 9 | 10 | ~/filename is relative to the user's home directory. 11 | !/filename is relative to the configuration directory. 12 | 13 | 14 | If a file is placed in the batch directory, it may be loaded simply by 15 | entering it's name at the prompt. The default location for batch files is 16 | 17 | ~/.config/pigiron/batch/ 18 | 19 | 20 | Care should be used in naming files in the batch directory so they do not 21 | shadow existing command names. 22 | 23 | -------------------------------------------------------------------------------- /resources/help/clear-macros: -------------------------------------------------------------------------------- 1 | Command clear-macros 2 | OSC /pig/clear-macros 3 | 4 | Deletes all macro definitions. 5 | 6 | See also macro, q-macros, del-macro -------------------------------------------------------------------------------- /resources/help/config: -------------------------------------------------------------------------------- 1 | Pigiron configuration 2 | 3 | On startup Pigiron attempts to read a default configuration file located at: 4 | 5 | Linux: : ~/.config/pigiron/config.toml 6 | OSX : To be determined 7 | Windows : To be determined 8 | 9 | An alternate file may be specified by the --config command line option. 10 | 11 | If a configuration file can not be loaded reasonable defaults will be 12 | selected. Enter the command 'print-config' to see the current configuration 13 | values. 14 | 15 | 16 | The config file is in TOML (https://en.wikipedia.org/wiki/TOML) format with 17 | a typical example below: 18 | 19 | [000] : # Pigiron configuration # -> comment 20 | [001] : # 21 | [002] : 22 | [003] : [log] 23 | [004] : enable = false # enable logging 24 | [005] : logfile = "!/log" # log output file. See below 25 | [006] : 26 | [007] : [osc-server] 27 | [008] : root = "pig" # OSC address prefix 28 | [009] : host = "127.0.0.1" 29 | [010] : port = 8020 30 | [011] : 31 | [012] : [osc-client] 32 | [013] : root = "pig-client" 33 | [014] : host = "127.0.0.1" 34 | [015] : port = 8021 35 | [016] : file = "/home/sj/t/foo" # Alternate file for OSC responses 36 | [017] : 37 | [018] : [tree] 38 | [019] : max-depth = 12 # Maximum number of operators 39 | [020] : # between a root and final leaf. 40 | [021] : [midi-input] 41 | [022] : buffer-size = 1024 # portmidi device parameter 42 | [023] : poll-interval = 0 # MIDI input polling interval in msec 43 | [024] : 44 | [025] : [midi-output] 45 | [026] : buffer-size = 1024 # portmidi device parameter 46 | [027] : latency = 0 # portmidi device parameter 47 | [028] : 48 | [029] : 49 | [030] : # Named terminal colors may be: 50 | [031] : # red, green, yellow, blue, purple, cyan, gray and white 51 | [032] : # Terminal colors are not available on Windows. 52 | [033] : 53 | [034] : [colors] 54 | [035] : banner = "green" 55 | [036] : text = "gray" 56 | [037] : error = "yellow" 57 | 58 | In general filenames throughout Pigiron may be prefixed with one of two 59 | special directories: 60 | 61 | ~/ as in ~/foo indicates the file is relative to the user's home directory. 62 | !/ as in !/foo indicates the file is relative to the pigiron 63 | configuration directory. 64 | 65 | -------------------------------------------------------------------------------- /resources/help/connect: -------------------------------------------------------------------------------- 1 | Command connect op1, op2 [, op3, ..., opn] 2 | OSC /pig/connect op1, op2 [, op3, ..., opn] 3 | 4 | Connects named operators op1 --> op2. 5 | If additional operators are specified they are connected in sequence. 6 | op1 --> op2 --> op3 --> ... --> opn 7 | 8 | It is not an error to connect operators which are already connected. 9 | 10 | OSC returns: ACK if the connections where successful. 11 | ERROR if the max tree depth is exceeded or if a circular tree 12 | would have been created. 13 | 14 | -------------------------------------------------------------------------------- /resources/help/del-all: -------------------------------------------------------------------------------- 1 | Command del-all 2 | OSC /pig/del-all 3 | 4 | Deletes all operators, except MIDIInputs. 5 | 6 | OSC returns: ACK. 7 | 8 | -------------------------------------------------------------------------------- /resources/help/del-macro: -------------------------------------------------------------------------------- 1 | Command del-macro 2 | OSC /pig/del-macro name 3 | 4 | Deletes named macro. 5 | 6 | See also macro, q-macros, celar-macros -------------------------------------------------------------------------------- /resources/help/del-op: -------------------------------------------------------------------------------- 1 | Command del-op name 2 | OSC /pig/del-op name 3 | 4 | Deletes the named operator. 5 | MIDIInput Operators can not be deleted 6 | 7 | OSC Returns: ACK if operator was deleted. 8 | ERROR if operator name does not exists or operator is a MIDIInput. 9 | -------------------------------------------------------------------------------- /resources/help/deselect-all-channels: -------------------------------------------------------------------------------- 1 | Command deselect-all-channels name 2 | OSC /pig/deselect-all-channels name 3 | 4 | Disables all MIDI channels for named operator. 5 | For single mode operators, channel 1 will be selected. 6 | 7 | OSC Return: ACk 8 | ERROR if operator does not exists. -------------------------------------------------------------------------------- /resources/help/deselect-channels: -------------------------------------------------------------------------------- 1 | Command deselect-channels name, chan1 [,chan2, chan3, ...] 2 | OSC /pig/deselect-channels name, chan1 [,chan2, chan3, ...] 3 | 4 | Disables all listed MIDI channels for named operator 5 | 6 | OSC Return: ACK list of enabled MIDI channels. 7 | ERROR if operator does not exists or MIDI channel invalid. -------------------------------------------------------------------------------- /resources/help/disconnect-all: -------------------------------------------------------------------------------- 1 | Command disconnect-all parent 2 | OSC /pig/disconnect-all parent 3 | 4 | Disconnects all children from parent operator. 5 | 6 | OSC Return: ACK 7 | ERROR if parent operator does not exists. -------------------------------------------------------------------------------- /resources/help/disconnect-child: -------------------------------------------------------------------------------- 1 | Command disconnect-child parent, child 2 | OSC /pig/disconnect-child parent, child 3 | 4 | Removes connection between parent and child operators. 5 | It is not an error if the two operators are not currently connected. 6 | 7 | OSC Return: ACK 8 | ERROR if either operator name does not exists. -------------------------------------------------------------------------------- /resources/help/disconnect-parents: -------------------------------------------------------------------------------- 1 | Command disconnect-parents child 2 | OSC /pig/disconnect-parents child 3 | 4 | Disconnects all parents to child operator. 5 | 6 | OSC Return: ACK 7 | ERROR if child operator does not exists. -------------------------------------------------------------------------------- /resources/help/enable-midi: -------------------------------------------------------------------------------- 1 | Command enable-midi name, bool 2 | OSC /pig/enable-midi name, bool 3 | 4 | Enables/disable MIDI output for named operator. 5 | 6 | OSC Return: ACK 7 | ERROR if operator does not exists, or bool not valid 8 | Boolean value. -------------------------------------------------------------------------------- /resources/help/exec: -------------------------------------------------------------------------------- 1 | Command exec command 2 | OSC /pig/exec command 3 | 4 | Executes command. 5 | 6 | exec allows sending a single string containing an OSC command. For example 7 | sending: 8 | 9 | /pig/exec "new MIDIPlayer, player" 10 | 11 | executes: 12 | 13 | /pig/new MIDIPlayer, player 14 | 15 | exec was added specifically to allow CYCO to function as a Pigiron 16 | client. -------------------------------------------------------------------------------- /resources/help/exit: -------------------------------------------------------------------------------- 1 | Command exit 2 | OSC /pig/exit 3 | 4 | Terminates Pigiron 5 | 6 | OSC Returns: ACK -------------------------------------------------------------------------------- /resources/help/help: -------------------------------------------------------------------------------- 1 | Command help topic [filter] 2 | OSC /pig/help topic [filter] 3 | 4 | Displays help for named topic. 5 | Use 'help topics' for list of available topics 6 | 7 | You may narrow the topics list by including an optional 'filter' term. 8 | Only topics which contain the filter text as a sub-string will be listed. 9 | 10 | 11 | 12 | OSC Return: ACK help text. 13 | ERROR if topic does not exists. -------------------------------------------------------------------------------- /resources/help/info: -------------------------------------------------------------------------------- 1 | Command info name 2 | OSC /pig/info name 3 | 4 | Prints information about named operator. 5 | 6 | OSC Return: ACK 7 | ERROR if operator does not exists. -------------------------------------------------------------------------------- /resources/help/invert-channels: -------------------------------------------------------------------------------- 1 | Command invert-channels name 2 | OSC /pig/invert-channels name 3 | 4 | Invert MIDI channel selection for named operator. 5 | Has no effect foe single channel operators. 6 | 7 | OSC Return: ACK list of selected channels 8 | ERROR if operator does not exists. -------------------------------------------------------------------------------- /resources/help/macro: -------------------------------------------------------------------------------- 1 | Command macro name, command, template... 2 | OSC /pig/macro name, command, template... 3 | 4 | Defines new macro. 5 | 6 | A macro replaces name with command and template. For example 7 | 8 | /pig/macro p, ping 9 | 10 | Creates a new macro named 'p' with expands to 'ping'. Entering p at the 11 | prompt executes ping. 12 | 13 | /pig/p --> ping 14 | 15 | The optional template values specifies arguments to the expanded command. 16 | Template values may be: 17 | 18 | 1) Literal strings 19 | 2) Have special form '$n' where n is an integer index. Upon macro 20 | execution '$n' is replaced with the nth argument. The following 21 | defines a replacement for 'new MIDIInput' 22 | 23 | 24 | /pig/macro input, new, MIDIInput, $0, $1 25 | 26 | Calling /pig/input dog, Arturia 27 | Expands to: new MIDIInput, dog, Arturia 28 | 29 | 30 | A practical example to simplify interaction with MIDIPlayer. 31 | 32 | /pig: new MIDIPlayer, player 33 | /pig: macro play, op, player, play 34 | /pig: macro stop, op, player, stop 35 | /pig: macro load, op, player, load, $0 36 | 37 | Hereafter to load a MIDI file simply enter 'load filename' instead of the 38 | more cumbersome 'op player, load, filename' 39 | 40 | 41 | Macros may not refer to previous macros. The following example will not 42 | work. 43 | 44 | /pig: macro foo, ping 45 | /pig: macro baz, foo 46 | 47 | The baz macro will fail. 48 | 49 | Macros are only applicable to the REPL command line or inside a batch 50 | file. They do not define new OSC commands. 51 | 52 | 53 | See also q-macros, del-macro, clear-macros 54 | 55 | 56 | -------------------------------------------------------------------------------- /resources/help/midi: -------------------------------------------------------------------------------- 1 | Command midi name, byte1, byte2, ..., byteN 2 | OSC /pig/midi name, byte1, byte2, ..., byteN 3 | 4 | Sends MIDI messages to named operator. 5 | 6 | The messages are specified as a list of bytes and more then one message may 7 | be sent. Numeric values may be expressed in decimal (default), binary or 8 | hex. Binary values are indicated by the prefix %, as in %1001. Hex 9 | values have the prefix 0x, as in 0xFF. 10 | 11 | For System exclusive messages the byte sequence must terminate with 12 | end-of-exclusive status 0xF7. 13 | 14 | OSC Return: ACK 15 | ERROR if operator does not exists or MIDI data invalid. -------------------------------------------------------------------------------- /resources/help/new: -------------------------------------------------------------------------------- 1 | Command new op-type, name 2 | OSC /pig/new op-type, name 3 | 4 | Creates new operator of given type and name. 5 | Use the q-operator-type command for a list of available types. 6 | 7 | A slightly different syntax is used for creating MIDIInput and 8 | MIDIOutput operators: 9 | 10 | new MIDIInput, name, device 11 | new MIDIOutput, name, device 12 | 13 | Use the q-midi-inputs and q-midi-output commands for a list of MIDI 14 | devices. The device may be specified either by its integer position in the 15 | list or by a unique sub-string of it's name. 16 | 17 | 18 | Operator names must be unique. If an attempt is made to create a new 19 | operator with the same name and type as an existing operator, the existing 20 | operator is reused and a new operator is not created. 21 | 22 | It is an error to create a new operator with the same name, but different 23 | type, of an existing operator. 24 | 25 | 26 | OSC Returns: ACK The operator name. 27 | ERROR if: 28 | 1) op-type is invalid. 29 | 2) an operator name exist and it's type is not op-type. 30 | -------------------------------------------------------------------------------- /resources/help/op: -------------------------------------------------------------------------------- 1 | Command op name, sub-command [,arguments ...] 2 | OSC /pig/op name, sub-command [,arguments ...] 3 | 4 | Sends sub-command to named operator. 5 | 6 | Syntax note: All values to the right of 'op' are argument to op and 7 | must be separated by commas. 8 | 9 | The avaliable sub-commands depends on the specific operator type. For 10 | operator-type Foo, use 'help Foo' for details. 11 | 12 | 13 | OSC Return: Is depended on specific sub-command -------------------------------------------------------------------------------- /resources/help/ping: -------------------------------------------------------------------------------- 1 | Command ping 2 | OSC /pig/ping 3 | 4 | Diagnostic to check OSC connectivity. 5 | Prints the word 'ping' to terminal 6 | 7 | OSC Returns: ACK -------------------------------------------------------------------------------- /resources/help/print-config: -------------------------------------------------------------------------------- 1 | Command print-config 2 | OSC /pig/print-config 3 | 4 | Prints configuration values. 5 | 6 | OSC Return: ACK -------------------------------------------------------------------------------- /resources/help/print-graph: -------------------------------------------------------------------------------- 1 | Command print-graph 2 | OSC /pig/print-graph 3 | 4 | Prints graphic representation of the MIDI process tree to the terminal. 5 | 6 | OSC Return: ACK -------------------------------------------------------------------------------- /resources/help/q-channel-mode: -------------------------------------------------------------------------------- 1 | Command q-channel-mode name 2 | OSC /pig/q-channel-mode name 3 | 4 | Returns the MIDI channel selection mode of named operator. 5 | There are three possible values: 6 | 7 | NoChannel - No channel selection. 8 | SingleChannel - One, and only one, channel is selected at any time. 9 | MultiChannel - Any number of channels may be selected simultaneously. 10 | 11 | OSC Return: ACK the channel mode. 12 | ERROR if operator does not exist. -------------------------------------------------------------------------------- /resources/help/q-channel-selected: -------------------------------------------------------------------------------- 1 | Command q-channel-selected name channel 2 | OSC /pig/q-channel-selected name channel 3 | 4 | Checks if specific MIDI channel is enabled for named operator. 5 | 6 | OSC Return: ACK bool 7 | ERROR if operator does not exists. -------------------------------------------------------------------------------- /resources/help/q-channels: -------------------------------------------------------------------------------- 1 | Command q-channels name 2 | OSC /pig/q-channels name 3 | 4 | Returns list of active MIDI channels for named operator 5 | 6 | OSC Return: ACK list of MIDI channels 7 | ERROR of operator does not exists. -------------------------------------------------------------------------------- /resources/help/q-children: -------------------------------------------------------------------------------- 1 | Command q-children name 2 | OSC /pig/q-children name 3 | 4 | Prints list of all children of named operator. 5 | 6 | OSC Return: ACK list of children operators. 7 | ERROR if operator does not exists. -------------------------------------------------------------------------------- /resources/help/q-commands: -------------------------------------------------------------------------------- 1 | Command q-commands 2 | OSC /pig/q-commands [filter] 3 | 4 | Prints list of commands. 5 | 6 | The optional filter term may be used to limit the results. 7 | If filter is present, only those commands which contain the filter text as a 8 | sub-string are listed. 9 | 10 | 11 | OSC Return: ACK list of commands. -------------------------------------------------------------------------------- /resources/help/q-graph: -------------------------------------------------------------------------------- 1 | Command q-graph 2 | OSC /pig/q-graph 3 | 4 | Prints set of oerpator connections. 5 | 6 | OSC Return: ACK list of operator connections. -------------------------------------------------------------------------------- /resources/help/q-macros: -------------------------------------------------------------------------------- 1 | Command q-macros 2 | OSC /pig/q-macros 3 | 4 | Displays list of defined macros. 5 | 6 | See also macro, del-macro, clear-macro 7 | -------------------------------------------------------------------------------- /resources/help/q-midi-enabled: -------------------------------------------------------------------------------- 1 | Command q-midi-enabled name 2 | OSC /pig/q-midi-enabled name 3 | 4 | Checks if MIDI is enabled for named operator. 5 | 6 | OSC Return: ACK bool 7 | ERROR if operator does not exists. -------------------------------------------------------------------------------- /resources/help/q-midi-inputs: -------------------------------------------------------------------------------- 1 | Command q-midi-inputs 2 | OSC /pig/q-midi-inputs 3 | 4 | OSC Returns: list of MIDI input devices. -------------------------------------------------------------------------------- /resources/help/q-midi-outputs: -------------------------------------------------------------------------------- 1 | Command q-midi-outputs 2 | OSC /pig/q-midi-outputs 3 | 4 | OSC Returns: list of MIDI output devices. -------------------------------------------------------------------------------- /resources/help/q-operator-types: -------------------------------------------------------------------------------- 1 | Command q-operator-types 2 | OSC /pig/q-operator-types 3 | 4 | List available operator types. 5 | 6 | OSC Return: ACK list of operator types. -------------------------------------------------------------------------------- /resources/help/q-operators: -------------------------------------------------------------------------------- 1 | Command q-operators 2 | OSC /pig/q-operators 3 | 4 | Prints list of active operators. 5 | 6 | OSC Return: ACK list of operators. -------------------------------------------------------------------------------- /resources/help/q-parents: -------------------------------------------------------------------------------- 1 | Command q-parents name 2 | OSC /pig/q-parents name 3 | 4 | Prints list of all parents for named operator. 5 | 6 | OSC Return: ACK list of parents 7 | ERROR if operator does not exists. -------------------------------------------------------------------------------- /resources/help/q-roots: -------------------------------------------------------------------------------- 1 | Command q-roots 2 | OSC /pig/q-roots 3 | 4 | Prints list of all root operators. 5 | 6 | OSC Return: ACK list of root operators. -------------------------------------------------------------------------------- /resources/help/reset-all: -------------------------------------------------------------------------------- 1 | Command reset-all 2 | OSC /pig/reset-all 3 | 4 | Resets all operators. 5 | 6 | OSC Return: ACK -------------------------------------------------------------------------------- /resources/help/reset-op: -------------------------------------------------------------------------------- 1 | Command reset-op name 2 | OSC /pig/reset-op name 3 | 4 | Resets named operator 5 | 6 | OSC Return: ACK 7 | ERROR if operator does not exists. -------------------------------------------------------------------------------- /resources/help/select-all-channels: -------------------------------------------------------------------------------- 1 | Command select-all-channels name 2 | OSC /pig/select-all-channels name 3 | 4 | Enable all MIDI channels for named operator. 5 | 6 | OSC Return: ACK 7 | ERROR if operator does not exists. -------------------------------------------------------------------------------- /resources/help/select-channels: -------------------------------------------------------------------------------- 1 | Command select-channels name, chan1 [,chan2, chan3, ....] 2 | OSC /pig/select-channels name, chan1 [,chan2, chan3, ....] 3 | 4 | Selects listed MIDI channels as enabled for named operator 5 | 6 | OSC Return: ACK list of enabled channel 7 | ERROR if operator does not exists or MIDI channel is invalid. -------------------------------------------------------------------------------- /resources/testFiles/README: -------------------------------------------------------------------------------- 1 | pigiron/resources/testFiles 2 | 3 | This directory contains various MIDI files for SMF testing. 4 | 5 | Files named a?.mid are well formed MIDI files. 6 | Files named b?.mid are malformed 7 | Files named c?.mid are mallformed but should be recoverable 8 | 9 | a1.mid 10 | very basic, single track, division 24, 1-bar 4 quarter notes on channel 1. 11 | tempo 60 BPM 12 | 13 | a2.mid 14 | same as a1.mid except 4-bars long, note a t start of each bar. 15 | 16 | 17 | b1.mid 18 | Not a MIDI file 19 | 20 | 21 | b2.mid 22 | Not a MIDI file, wrong header chunk id 23 | 24 | b3.mid 25 | Wrong header byte count 26 | 27 | 28 | 29 | c1.mid 30 | Invalid format 31 | 32 | c2.mid 33 | Invalid division -------------------------------------------------------------------------------- /resources/testFiles/a1.mid: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plewto/Pigiron/df277253d9fabaecd9a46e6da52c7676a5549eeb/resources/testFiles/a1.mid -------------------------------------------------------------------------------- /resources/testFiles/a2.mid: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plewto/Pigiron/df277253d9fabaecd9a46e6da52c7676a5549eeb/resources/testFiles/a2.mid -------------------------------------------------------------------------------- /resources/testFiles/b1.mid: -------------------------------------------------------------------------------- 1 | This is not a MIDI file. -------------------------------------------------------------------------------- /resources/testFiles/b2.mid: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plewto/Pigiron/df277253d9fabaecd9a46e6da52c7676a5549eeb/resources/testFiles/b2.mid -------------------------------------------------------------------------------- /resources/testFiles/b3.mid: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plewto/Pigiron/df277253d9fabaecd9a46e6da52c7676a5549eeb/resources/testFiles/b3.mid -------------------------------------------------------------------------------- /resources/testFiles/c1.mid: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plewto/Pigiron/df277253d9fabaecd9a46e6da52c7676a5549eeb/resources/testFiles/c1.mid -------------------------------------------------------------------------------- /resources/testFiles/c2.mid: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plewto/Pigiron/df277253d9fabaecd9a46e6da52c7676a5549eeb/resources/testFiles/c2.mid -------------------------------------------------------------------------------- /smf/chunk.go: -------------------------------------------------------------------------------- 1 | package smf 2 | 3 | /* 4 | ** chunk.go defines a generalized MIDI file chunk. 5 | ** In practice there are two type of chunks: 6 | ** 1. Header id = 'MThd' 7 | ** 2. Track id = 'MTrk' 8 | ** 9 | */ 10 | 11 | import ( 12 | "fmt" 13 | "os" 14 | "github.com/plewto/pigiron/pigerr" 15 | ) 16 | 17 | 18 | // chunkID type represents a 4-byte chunk-type code. 19 | // 20 | type chunkID [4]byte 21 | 22 | func (id *chunkID) String() string { 23 | s := "chunkID '%c%c%c%c'" 24 | return fmt.Sprintf(s, id[0], id[1], id[2], id[3]) 25 | } 26 | 27 | // chunkID.eq returns true iff two chunks are equivalent. 28 | // 29 | func (id chunkID) eq(other chunkID) bool { 30 | for i := 0; i < len(id); i++ { 31 | if id[i] != other[i] { 32 | return false 33 | } 34 | } 35 | return true 36 | } 37 | 38 | 39 | // Chunk interface defines common methods for MIDI file chunks. 40 | // 41 | type Chunk interface { 42 | ID() chunkID 43 | Length() int // number of bytes 44 | } 45 | 46 | // readChunkPreamble(f *osFile) reads the next 8-bytes from an open file as the start of a chunk. 47 | // 48 | // Returns: 49 | // id - 4-byte chunkID 50 | // length - number of remaining bytes in the chunk 51 | // error - no-nil if the data dose not look like the start of a chunk. 52 | // 53 | func readChunkPreamble(f *os.File) (id chunkID, length int, err error) { 54 | var buffer = make([]byte, 8) 55 | var n = 0 56 | n, err = f.Read(buffer) 57 | if n != 8 && err == nil { 58 | errmsg := "smf.readChunkPreamble, file does not contain minimal number of byte." 59 | err = pigerr.New(errmsg) 60 | return id, length, err 61 | } 62 | if err != nil { 63 | err = pigerr.CompoundError(err, "smf.readChunkPreamble, can not read chunk") 64 | return id, length, err 65 | } 66 | for i := 0; i < 4; i++ { 67 | id[i] = buffer[i] 68 | } 69 | length, _, err = TakeLong(buffer[4:]) 70 | return id, length, err 71 | } 72 | 73 | // readRawChunk(f *osFile) reads chuck data from open file. 74 | // The file's read pointer should be positioned at the start of the chunk's 75 | // 4-byte id. 76 | // 77 | // Returns: 78 | // id - 4-byte chunk id 79 | // data - chucks contents 80 | // error - non-nil if the chunk could not be read. 81 | // 82 | func readRawChunk(f *os.File) (id chunkID, data []byte, err error) { 83 | var length int 84 | id, length, err = readChunkPreamble(f) 85 | if err != nil { 86 | return 87 | } 88 | data = make([]byte, length) 89 | var count int 90 | count, err = f.Read(data) 91 | if err != nil { 92 | errmsg := "smf.readRawChunk could not read chunk values." 93 | err = pigerr.CompoundError(err, errmsg) 94 | return 95 | } 96 | if count != length { 97 | errmsg := "smf.readRawChunk read value count inconsistent.\n" 98 | errmsg += "Expected %d bytes, read %d" 99 | err = pigerr.New(fmt.Sprintf(errmsg, length, count)) 100 | return 101 | } 102 | return 103 | } 104 | 105 | 106 | 107 | -------------------------------------------------------------------------------- /smf/chunk_test.go: -------------------------------------------------------------------------------- 1 | package smf 2 | 3 | import ( 4 | "testing" 5 | "fmt" 6 | "os" 7 | "github.com/plewto/pigiron/pigpath" 8 | ) 9 | 10 | func openTestFile(t *testing.T, name string) (*os.File, string) { 11 | filename := pigpath.ResourceFilename("testFiles", name) 12 | file, err := os.Open(filename) 13 | if err != nil { 14 | errmsg := "\nCan not open test file: '%s'" 15 | errmsg += "\n%s\n" 16 | t.Fatalf(errmsg, filename, err) 17 | } 18 | return file, filename 19 | } 20 | 21 | 22 | 23 | func TestReadChuckPreamble(t *testing.T) { 24 | file, filename := openTestFile(t, "a1.mid") 25 | defer file.Close() 26 | id, length, err := readChunkPreamble(file) 27 | if err != nil { 28 | errmsg := "\nreadChunkPreamble returned unexpected error for know good file." 29 | errmsg += "\nfilename was '%s'" 30 | errmsg += "\n%s\n" 31 | t.Fatalf(errmsg, filename, err) 32 | } 33 | if !chunkID(id).eq(headerID) { 34 | errmsg := "\nreadChunkPreamble expected id %s, got %s" 35 | t.Fatalf(fmt.Sprintf(errmsg, headerID), chunkID(id)) 36 | } 37 | if length != 6 { 38 | errmsg := "\nreadChunkPreamble expected header length 6, got %d" 39 | t.Fatalf(fmt.Sprintf(errmsg, length)) 40 | } 41 | } 42 | 43 | 44 | func TestReadRawChunk(t *testing.T) { 45 | file, filename := openTestFile(t, "a1.mid") 46 | defer file.Close() 47 | // read header 48 | id, data, err := readRawChunk(file) 49 | if err != nil { 50 | errmsg := "\nreadRawChunk returned unexpected error for know good file." 51 | errmsg += "\nfilename was '%s'" 52 | errmsg += "\n%s\n" 53 | t.Fatalf(errmsg, filename, err) 54 | } 55 | if !id.eq(headerID) { 56 | errmsg := "n\readRawChunk expected headerID, got %s" 57 | t.Fatalf(fmt.Sprintf(errmsg, id)) 58 | } 59 | if len(data) != 6 { 60 | errmsg := "\nreadRawChunk expected header data length 6, got %d" 61 | t.Fatalf(fmt.Sprintf(errmsg, len(data))) 62 | } 63 | // read track 64 | id, data, err = readRawChunk(file) 65 | if err != nil { 66 | errmsg := "\nreadRawChunk could not read track chunk\n%s\n" 67 | t.Fatalf(errmsg, err) 68 | } 69 | if !id.eq(trackID) { 70 | errmsg := "\nreadRawChuck expected track id '%s', got '%s'\n" 71 | t.Fatalf(errmsg, trackID, id) 72 | } 73 | // file contents should be exhausted. 74 | buffer := make([]byte, 10) 75 | n, _ := file.Read(buffer) 76 | if n != 0 { 77 | errmsg := "\nreadRawChunk expected to have read all bytes, found additional values" 78 | t.Fatalf(errmsg) 79 | } 80 | } 81 | 82 | 83 | -------------------------------------------------------------------------------- /smf/event.go: -------------------------------------------------------------------------------- 1 | package smf 2 | 3 | import ( 4 | "fmt" 5 | gomidi "gitlab.com/gomidi/midi/v2" 6 | ) 7 | 8 | // Event struct combines MIDI message with delta time. 9 | // 10 | type Event struct { 11 | deltaTime uint64 12 | message gomidi.Message 13 | } 14 | 15 | func (ev *Event) String() string { 16 | return fmt.Sprintf("Δt %8d : %s", ev.deltaTime, ev.message) 17 | } 18 | 19 | // Event.Length() method returns byte count of event message. 20 | // 21 | func (ev *Event) Length() int { 22 | return len(ev.message.Data) 23 | } 24 | 25 | func (ev *Event) Dump() { 26 | fmt.Println(ev.String()) 27 | } 28 | 29 | func (ev *Event) DeltaTime() uint64 { 30 | return ev.deltaTime 31 | } 32 | 33 | func (ev *Event) Message() gomidi.Message { 34 | return ev.message 35 | } 36 | 37 | -------------------------------------------------------------------------------- /smf/expect.go: -------------------------------------------------------------------------------- 1 | package smf 2 | 3 | 4 | /* 5 | * expect.go defines functions to convert byte array to MIDI Events. 6 | * 7 | */ 8 | 9 | import ( 10 | "fmt" 11 | "github.com/plewto/pigiron/midi" 12 | ) 13 | 14 | func ExpectByte(buffer []byte, index int) (value byte, newIndex int, err error) { 15 | if index >= len(buffer) { 16 | errmsg := "ExpectByte, index out of bounds: %d" 17 | err = fmt.Errorf(errmsg, index) 18 | return 19 | } 20 | value = buffer[index] 21 | newIndex = index + 1 22 | return 23 | } 24 | 25 | 26 | func ExpectVLQ(buffer []byte, index int) (vlq *VLQ, newIndex int, err error) { 27 | var maxBytes = 4 28 | var acc = make([]byte, 0, maxBytes) 29 | count := 0 30 | if index >= len(buffer) { 31 | errmsg := "ExpectVLQ index out of bounds: %d" 32 | err = fmt.Errorf(errmsg, index) 33 | return 34 | } 35 | for i := index; i < len(buffer); i++ { 36 | count++ 37 | if count > maxBytes { 38 | errmsg := "expect.ExpectVLQ, expected VLQ at index %d" 39 | err = fmt.Errorf(errmsg, index) 40 | return 41 | } 42 | n := buffer[i] 43 | acc = append(acc, n) 44 | if n & 0x80 == 0 { 45 | break 46 | } 47 | } 48 | vlq = NewVLQ(0) 49 | vlq.setBytes(acc) 50 | newIndex = index + count 51 | return 52 | } 53 | 54 | func ExpectDataByte(buffer []byte, index int) (value byte, err error) { 55 | if index >= len(buffer) { 56 | errmsg := "expect.ExpectDataByte index out of bounds at %d" 57 | err = fmt.Errorf(errmsg, index) 58 | return 59 | } 60 | value = buffer[index] 61 | if value > 0x7F { 62 | errmsg := "expect.ExpectDataByte, expected MIDI data byte at index %d, got 0x%02X" 63 | err = fmt.Errorf(errmsg, index, value) 64 | } 65 | return 66 | } 67 | 68 | 69 | func ExpectRunningStatus(buffer []byte, status byte, index int) (mdata []byte, newIndex int, err error) { 70 | count := midi.ChannelMessageDataCount(midi.StatusByte(status)) 71 | if len(buffer) <= index+count { 72 | errmsg := "expect.ExpectRunningStatus index out of bounds %d, []byte length is %d" 73 | err = fmt.Errorf(errmsg, index, len(buffer)) 74 | return 75 | } 76 | var d1, d2 byte 77 | switch count { 78 | case 1: 79 | d1, err = ExpectDataByte(buffer, index) 80 | if err != nil { 81 | return 82 | } 83 | mdata = []byte{status, d1} 84 | newIndex = index + 1 85 | case 2: 86 | d1, err = ExpectDataByte(buffer, index) 87 | if err != nil { 88 | return 89 | } 90 | d2, err = ExpectDataByte(buffer, index+1) 91 | if err != nil { 92 | return 93 | } 94 | mdata = []byte{status, d1, d2} 95 | newIndex = index + 2 96 | default: 97 | errmsg := "expect.ExpectRunningStatus swtich fallthrough. Status byte was 0x%02X" 98 | err = fmt.Errorf(errmsg, status) 99 | } 100 | return 101 | } 102 | 103 | func ExpectChannelMessage(buffer []byte, status byte, index int) (mdata []byte, newIndex int, err error) { 104 | mdata, newIndex, err = ExpectRunningStatus(buffer, status, index+1) 105 | return 106 | } 107 | 108 | 109 | func ExpectSysexMessage(buffer []byte, index int) (mdata []byte, newIndex int, err error) { 110 | var acc = make([]byte, 1, 1024) 111 | if index >= len(buffer) { 112 | errmsg := "expect.ExpectSysexMessage index out of bounds at %d, []byte length is %d" 113 | err = fmt.Errorf(errmsg, index, len(buffer)) 114 | return 115 | } 116 | st := buffer[index] 117 | if st != 0xF0 { 118 | errmsg := "Expected sysex status 0xF0 at index %d, got 0x02X" 119 | err = fmt.Errorf(errmsg, index, st) 120 | } 121 | var b byte 122 | acc[0] = st 123 | index++ 124 | for { 125 | if index >= len(buffer) { 126 | errmsg := "expect.ExpectSysexMessage index out of bounds at %d, []byte length is %d" 127 | err = fmt.Errorf(errmsg, index, len(buffer)) 128 | return 129 | } 130 | b = buffer[index] 131 | switch { 132 | case b < 0x80: 133 | acc = append(acc, b) 134 | case b == 0xF7: 135 | acc = append(acc, b) 136 | index++ 137 | mdata = acc[0 : len(acc)] 138 | newIndex = index 139 | return 140 | case midi.IsSystemRealtimeStatus(midi.StatusByte(b)): 141 | // ignore 142 | case b >= 0x80: 143 | errmsg := "Sysex aborted by invalid status byte 0x%02X at index %d" 144 | err = fmt.Errorf(errmsg, b, index) 145 | return 146 | default: 147 | errmsg := "Sysex message aborted by unexpected status byte 0x%02X at index %d" 148 | err = fmt.Errorf(errmsg, b, index) 149 | return 150 | } 151 | index++ 152 | } 153 | return 154 | } 155 | 156 | 157 | 158 | // handles non-sysex system messages 159 | // Theses are all a single byte in length 160 | // 161 | func ExpectSystemMessage(buffer []byte, index int) (mdata []byte, newIndex int, err error) { 162 | if index >= len(buffer) { 163 | errmsg := "expect.ExpectSystemMessage index %d out of bounds, []byte length is %d" 164 | err = fmt.Errorf(errmsg, index, len(buffer)) 165 | return 166 | } 167 | st := buffer[index] 168 | if !midi.IsSystemStatus(midi.StatusByte(st)) { 169 | errmsg := "Expected MIDI real time system message at index %d, got 0x%02X" 170 | err = fmt.Errorf(errmsg, index, st) 171 | return 172 | } 173 | mdata = []byte{byte(st)} 174 | newIndex = index+1 175 | return 176 | } 177 | 178 | 179 | func ExpectMetaMessage(buffer []byte, index int) (mdata []byte, newIndex int, err error) { 180 | if index >= len(buffer)-1 { 181 | errmsg := "expect.ExpectMetaMessage index %d out of bounds, []byte length is %d" 182 | err = fmt.Errorf(errmsg, index, len(buffer)) 183 | return 184 | } 185 | st := buffer[index] 186 | mt := buffer[index+1] 187 | if !midi.IsMetaStatus(midi.StatusByte(st)) || !midi.IsMetaType(midi.MetaType(mt)) { 188 | errmsg := "Expected meta status 0xFF and valid meta type starting at index %d, " 189 | errmsg += "got 0x%02x and 0x$02x" 190 | err = fmt.Errorf(errmsg, index, byte(st), byte(mt)) 191 | return 192 | } 193 | acc := make([]byte, 2, 128) 194 | acc[0] = byte(st) 195 | acc[1] = byte(mt) 196 | index += 2 197 | var vlq *VLQ 198 | vlq, index, err = ExpectVLQ(buffer, index) 199 | if err != nil { 200 | return 201 | } 202 | for _, b := range vlq.Bytes() { 203 | acc = append(acc, b) 204 | } 205 | for j, count := index, 0; count < vlq.Value(); j, count = j+1, count+1 { 206 | if j >= len(buffer) { 207 | errmsg := "expect.ExpectMetaMesage index %d out of bounds, []byte length is %d" 208 | err = fmt.Errorf(errmsg, j, len(buffer)) 209 | return 210 | } 211 | acc = append(acc, buffer[j]) 212 | index++ 213 | } 214 | mdata = acc[0 : len(acc)] 215 | newIndex = index 216 | return 217 | } 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | -------------------------------------------------------------------------------- /smf/header.go: -------------------------------------------------------------------------------- 1 | package smf 2 | 3 | /* 4 | ** header.go defines MIDI file header chunk. 5 | ** 6 | */ 7 | 8 | import ( 9 | "fmt" 10 | "os" 11 | "github.com/plewto/pigiron/pigerr" 12 | ) 13 | 14 | var headerID chunkID = [4]byte{0x4d, 0x54, 0x68, 0x64} 15 | 16 | 17 | // SMFheadr strut implements Chunk interface for MIDI file headers. 18 | // 19 | type Header struct { 20 | format int 21 | trackCount int // NOTE: trackCount may be greater then actual track count. 22 | division int 23 | } 24 | 25 | func (h *Header) String() string { 26 | msg := "smf.Header format: %d trackCount: %d division: %d" 27 | return fmt.Sprintf(msg, h.format, h.trackCount, h.division) 28 | } 29 | 30 | func (h *Header) ID() chunkID { 31 | return headerID 32 | } 33 | 34 | // h.Format() return MIDI file format 35 | // 36 | func (h *Header) Format() int { 37 | return h.format 38 | } 39 | 40 | 41 | // h.Division() returns MIDI file clock division. 42 | // 43 | func (h *Header) Division() int { 44 | return h.division 45 | } 46 | 47 | func (h *Header) Length() int { 48 | return 6 49 | } 50 | 51 | // h.Dump() displays contents of MIDI header chunk. 52 | // 53 | func (h *Header) Dump() { 54 | fmt.Println("Header:") 55 | fmt.Printf("\tformat : %4d\n", h.format) 56 | fmt.Printf("\tchuckCount : %4d\n", h.trackCount) 57 | fmt.Printf("\tdivision : %4d\n", h.division) 58 | } 59 | 60 | 61 | // readHeader function reads MIDI file header chuck from file. 62 | // 63 | func readHeader(f *os.File) (header *Header, err error) { 64 | var id chunkID 65 | var length int 66 | id, length, err = readChunkPreamble(f) 67 | if err != nil { 68 | return 69 | } 70 | if !id.eq(headerID) { 71 | msg := "Expected header id '%s', got '%s'" 72 | err = fmt.Errorf(msg, headerID, id) 73 | return 74 | } 75 | if length != 6 { 76 | msg := "Unusual SMF header length, expected 6, got %d\n" 77 | err = fmt.Errorf(msg, length) 78 | return 79 | } 80 | 81 | var data = make([]byte, length) 82 | var count = 0 83 | count, err = f.Read(data) 84 | if count != length { 85 | msg := "SMF Header data count inconsistent, expected %d bytes, read %d" 86 | err = fmt.Errorf(msg, count, length) 87 | return 88 | } 89 | if err != nil { 90 | msg := "smf.readHeader could not read Header chunk\n" 91 | msg += fmt.Sprintf("%s", err) 92 | err = fmt.Errorf(msg) 93 | return 94 | } 95 | // DO NOT replace above lines with readRawChunk() 96 | // It may not detect non-smf files and attmpt to read 97 | // huge amounts of data. 98 | // 99 | 100 | var format, trackCount, division int 101 | format, data, _ = TakeShort(data) 102 | trackCount, data, _ = TakeShort(data) 103 | division, _, _ = TakeShort(data) 104 | header = &Header{format, trackCount, division} 105 | if format < 0 || 2 < format { 106 | dflt := 0 107 | errmsg := "MIDI file has unsupported format: %d, using default %d" 108 | pigerr.Warning(fmt.Sprintf(errmsg, format, dflt)) 109 | header.format = dflt 110 | } 111 | if division < 24 || 960 < division { 112 | dflt := 24 113 | msg1 := "MIDI file has out of bounds clock division" 114 | msg2 := fmt.Sprintf("Expected division between 24 and 960, got %d", division) 115 | msg3 := fmt.Sprintf("Using default %d", dflt) 116 | pigerr.Warning(msg1, msg2, msg3) 117 | header.division = dflt 118 | } 119 | return 120 | } 121 | 122 | 123 | 124 | 125 | -------------------------------------------------------------------------------- /smf/header_test.go: -------------------------------------------------------------------------------- 1 | package smf 2 | 3 | import ( 4 | "testing" 5 | "fmt" 6 | "os" 7 | "github.com/plewto/pigiron/pigpath" 8 | ) 9 | 10 | 11 | func TestReadSMFHeader(t *testing.T) { 12 | 13 | fmt.Println("*** EXPECT TO SEE WARNINGS ***") 14 | openTestFile := func(name string) (*os.File, string) { 15 | filename := pigpath.ResourceFilename("testFiles", name) 16 | file, err := os.Open(filename) 17 | if err != nil { 18 | errmsg := "\nCan not open test file: '%s'" 19 | errmsg += "\n%s\n" 20 | t.Fatalf(errmsg, filename, err) 21 | } 22 | fmt.Printf("Using test file '%s'\n", filename) 23 | return file, filename 24 | } 25 | 26 | file, filename := openTestFile("a1.mid") 27 | defer file.Close() 28 | header, err := readHeader(file) 29 | if err != nil { 30 | errmsg := "\nreadHeader(\"%s\") returned unexpected error" 31 | errmsg += "\n%s\n" 32 | t.Fatalf(errmsg, filename, err) 33 | } 34 | if header.format != 1 { 35 | errmsg := "\nexpected format 2, got %d" 36 | t.Fatalf(errmsg, header.format) 37 | } 38 | if header.trackCount != 1 { 39 | errmsg := "\nexpected trackCount 1, got %d" 40 | t.Fatalf(errmsg, header.trackCount) 41 | } 42 | if header.division != 24 { 43 | errmsg := "\nexpected division 24, got %d" 44 | t.Fatalf(errmsg, header.division) 45 | } 46 | 47 | 48 | // Malformed files 49 | 50 | file, filename = openTestFile("b1.mid") 51 | defer file.Close() 52 | _, err = readHeader(file) 53 | if err == nil { 54 | errmsg := "\nreadHeader did not return error for non-midi file" 55 | errmsg += "\nfilename was %s\n" 56 | t.Fatalf(errmsg, filename) 57 | } 58 | 59 | file, filename = openTestFile("b2.mid") 60 | defer file.Close() 61 | _, err = readHeader(file) 62 | if err == nil { 63 | errmsg := "\nreadHeader did not return error for unexpected header id." 64 | errmsg += "\nfilename was %s\n" 65 | t.Fatalf(errmsg, filename) 66 | } 67 | 68 | file, filename = openTestFile("b3.mid") 69 | defer file.Close() 70 | _, err = readHeader(file) 71 | if err == nil { 72 | errmsg := "\nreadHeader did not detect wrong chunk length." 73 | errmsg += "\nfilename was %s\n" 74 | t.Fatalf(errmsg, filename) 75 | } 76 | 77 | // malformed & recoverable 78 | file, filename = openTestFile("c1.mid") 79 | defer file.Close() 80 | header, err = readHeader(file) 81 | if err != nil { 82 | errmsg := "\nreadHeader returned error for recoverable file, invalid format." 83 | errmsg += "\nfilename was %s\n" 84 | t.Fatalf(errmsg, filename) 85 | } 86 | if header.format != 0 { 87 | errmsg := "\nreadHeader did not correct invalid format to default" 88 | errmsg += "\nfilename was %s\n" 89 | t.Fatalf(errmsg, filename) 90 | } 91 | 92 | file, filename = openTestFile("c2.mid") 93 | defer file.Close() 94 | header, err = readHeader(file) 95 | if err != nil { 96 | errmsg := "\nreadHeader returned error for recoverable file, invalid division." 97 | errmsg += "\nfilename was %s\n" 98 | t.Fatalf(errmsg, filename) 99 | } 100 | if header.division != 24 { 101 | errmsg := "\nreadHeader did not correct invalid division to default" 102 | errmsg += "\nfilename was %s\n" 103 | t.Fatalf(errmsg, filename) 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /smf/smf.go: -------------------------------------------------------------------------------- 1 | package smf 2 | 3 | /* 4 | ** smf.go defines general structure for Standard MIDI Files. 5 | ** 6 | */ 7 | 8 | import ( 9 | "fmt" 10 | "os" 11 | "github.com/plewto/pigiron/pigpath" 12 | "github.com/plewto/pigiron/pigerr" 13 | ) 14 | 15 | // SMF struct defines a Standard MIDI File. 16 | // 17 | type SMF struct { 18 | filename string 19 | header *Header 20 | tracks[] Track 21 | } 22 | 23 | func NewSMF() *SMF { 24 | smf := new(SMF) 25 | smf.filename = "" 26 | smf.header = &Header{0, 1, 24} 27 | smf.tracks = make([]Track, 0, 1) 28 | return smf 29 | } 30 | 31 | 32 | func (smf *SMF) String() string { 33 | return fmt.Sprintf("SMF '%s'", smf.filename) 34 | } 35 | 36 | func (smf *SMF) Dump(verbose ...int) { 37 | fmt.Println("SMF") 38 | fmt.Printf("\tFilename : \"%s\"\n", smf.filename) 39 | fmt.Println("\tHeader") 40 | fmt.Printf("\t\tformat : %d\n", smf.Format()) 41 | fmt.Printf("\t\tdivision : %d\n", smf.Division()) 42 | fmt.Printf("\t\ttracks : %d\n", smf.TrackCount()) 43 | if len(verbose) == 0 { 44 | return 45 | } 46 | switch verbose[0] { 47 | case 1: // list track events only 48 | fmt.Println("\tTracks:") 49 | for i, trk := range smf.tracks { 50 | fmt.Printf("\t\t[trk %d] %d events\n", i, len(trk.events)) 51 | } 52 | case 2: // list full track details 53 | fmt.Println("\tTracks:") 54 | for i, trk := range smf.tracks { 55 | fmt.Printf("\t\t[trk %d] %d events\n", i, len(trk.events)) 56 | for j, ev := range trk.events { 57 | fmt.Printf("\t\t\t[%5d] %s\n", j, ev.String()) 58 | } 59 | } 60 | default: // ignor 61 | } 62 | } 63 | 64 | func (smf *SMF) Format() int { 65 | return smf.header.format 66 | } 67 | 68 | func (smf *SMF) Division() int { 69 | return smf.header.division 70 | } 71 | 72 | func (smf *SMF) TrackCount() int { 73 | return len(smf.tracks) 74 | } 75 | 76 | func (smf *SMF) Track(n int) (track Track, err error) { 77 | if n < 0 || smf.TrackCount() <= n { 78 | err = fmt.Errorf("SMF Track number out of bounds: %d", n) 79 | return 80 | } 81 | track = smf.tracks[n] 82 | return 83 | } 84 | 85 | func (smf *SMF) Filename() string { 86 | return smf.filename 87 | } 88 | 89 | func ReadSMF(filename string) (smf *SMF, err error) { 90 | filename = pigpath.SubSpecialDirectories(filename) 91 | file, ferr := os.Open(filename) 92 | if ferr != nil { 93 | errmsg := "Can not open SMF file: '%s'\n%s" 94 | err = fmt.Errorf(errmsg, filename, ferr.Error()) 95 | return 96 | } 97 | defer file.Close() 98 | smf = NewSMF() 99 | smf.header, err = readHeader(file) 100 | if err != nil { 101 | return smf, err 102 | } 103 | smf.tracks = make([]Track, 0, smf.header.trackCount) 104 | for i := 0; i < smf.header.trackCount; i++ { 105 | track, terr := readTrack(file) 106 | if terr != nil { 107 | errmsg := "Can not read track %d of smf file %s\n%s" 108 | err = fmt.Errorf(errmsg, i, filename, terr.Error) 109 | return 110 | } 111 | smf.tracks = append(smf.tracks, *track) 112 | } 113 | smf.filename = filename 114 | return 115 | } 116 | 117 | // TickDuration calculates duration of single clock tick. 118 | // Args: 119 | // division is smf clock Division. 120 | // tempo in BPM. 121 | // 122 | func TickDuration(division int, tempo float64) float64 { 123 | division = division & 0x7FFFF 124 | if tempo == 0 { 125 | dflt := 60.0 126 | errmsg := "MIDI tempo is 0, using default %f" 127 | pigerr.Warning(fmt.Sprintf(errmsg, dflt)) 128 | tempo = dflt 129 | } 130 | var qdur float64 = 60.0/tempo 131 | return qdur/float64(division) 132 | } 133 | 134 | // smf.Duration returns aproximate duration of track 0 in seconds. 135 | // 136 | func (smf *SMF) Duration() float64 { 137 | if len(smf.tracks) == 0 { 138 | return 0.0 139 | } 140 | var acc float64 = 0.0 141 | var tempo float64 = 120 142 | var tick = TickDuration(smf.Division(), tempo) 143 | var track = smf.tracks[0] 144 | for _, event := range track.events { 145 | msg := event.Message() 146 | if IsTempoChange(msg) { 147 | tempo, _ := MetaTempoBPM(msg) 148 | tick = TickDuration(smf.Division(), tempo) 149 | } 150 | acc += float64(event.deltaTime) * tick 151 | } 152 | return acc 153 | } 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | -------------------------------------------------------------------------------- /smf/smf_test.go: -------------------------------------------------------------------------------- 1 | package smf 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | ) 7 | 8 | var ( 9 | GOOD_FILES = []string{"!/resources/testFiles/a1.mid", 10 | "!/resources/testFiles/a2.mid"} 11 | BAD_FILES = []string{"!/resources/testFiles/b1.mid", 12 | "!/resources/testFiles/b2.mid", 13 | "!/resources/testFiles/b3.mid"} 14 | RECOVERABLE_FILES = []string{"!/resources/testFiles/c1.mid", 15 | "!/resources/testFiles/c2.mid"} 16 | ) 17 | 18 | 19 | func TestSMF(t *testing.T) { 20 | fmt.Println("TestSMF") 21 | for _, f := range GOOD_FILES { 22 | smf, err := ReadSMF(f) 23 | if err != nil { 24 | errmsg := "Unexpected error while reading smf file '%s'\n%s" 25 | t.Fatalf(errmsg, f, err.Error()) 26 | } 27 | fmt.Printf("test file: %s\n", smf.filename) 28 | } 29 | for _, f := range BAD_FILES { 30 | _, err := ReadSMF(f) 31 | if err == nil { 32 | errmsg := "Did not detect non-smf file: %s" 33 | t.Fatalf(errmsg, f) 34 | } 35 | } 36 | fmt.Println("*** EXPECT TO SEE WARNINGS ***") 37 | for _, f := range RECOVERABLE_FILES { 38 | _, err := ReadSMF(f) 39 | if err != nil { 40 | errmsg := "Did not read recoverable SMF file: %s\n%s" 41 | t.Fatalf(errmsg, f, err.Error()) 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /smf/take.go: -------------------------------------------------------------------------------- 1 | package smf 2 | 3 | /* 4 | ** take.go defines functions for extracting values from byte slices. 5 | ** Includes miscellaneous utility functions. 6 | */ 7 | 8 | import ( 9 | "fmt" 10 | "github.com/plewto/pigiron/midi" 11 | "github.com/plewto/pigiron/pigerr" 12 | ) 13 | 14 | 15 | // msb(n) function returns upper byte of 16-bit value. 16 | // 17 | func msb(n int) byte { 18 | hi := (n & 0xFF00) >> 8 19 | return byte(hi) 20 | } 21 | 22 | // lsb(n) function returns the lower byte of 16-bit value. 23 | // 24 | func lsb(n int) byte { 25 | return byte(n & 0x00FF) 26 | } 27 | 28 | // reverse order of byte slice. 29 | // 30 | func reverse(src []byte) []byte { 31 | acc := make([]byte, len(src)) 32 | for i, j := len(src)-1, 0; j < len(src); i, j = i-1, j+1 { 33 | acc[j] = src[i] 34 | } 35 | return acc 36 | } 37 | 38 | 39 | // requireBufferLength() returns error if buffer is not at least count bytes long. 40 | // 41 | func requireBufferLength(buffer []byte, count int) error { 42 | var err error 43 | if len(buffer) < count { 44 | errmsg := "Require byte buffer of at least %d bytes, got %d" 45 | err = pigerr.New(fmt.Sprintf(errmsg, count, len(buffer))) 46 | } 47 | return err 48 | } 49 | 50 | // TakeByte() returns first byte in buffer. 51 | // Returns: 52 | // value - the first byte of buffer 53 | // newBuffer - slice of buffer starting after first byte. 54 | // error - non-nil if buffer is empty. 55 | // 56 | func TakeByte(buffer []byte) (value byte, newBuffer []byte, err error) { 57 | err = requireBufferLength(buffer, 1) 58 | if err != nil { 59 | return 0, []byte{}, err 60 | } 61 | return buffer[0], buffer[1:], err 62 | } 63 | 64 | func TakeStatusByte(buffer []byte) (value midi.StatusByte, newBuffer []byte, err error) { 65 | var bvalue byte 66 | bvalue, newBuffer, err = TakeByte(buffer) 67 | return midi.StatusByte(bvalue), newBuffer, err 68 | } 69 | 70 | 71 | // TakeShort() returns first two buffer bytes as 16-bit int. 72 | // Returns: 73 | // value - 16-bit 'short' value 74 | // newBuffer - slice of buffer starting at index 2. 75 | // error - non-nil if buffer length less then 2. 76 | // 77 | func TakeShort(buffer []byte) (value int, newBuffer []byte, err error) { 78 | err = requireBufferLength(buffer, 2) 79 | if err != nil { 80 | return 0, []byte{}, err 81 | } 82 | b1, b2 := int(buffer[0]), int(buffer[1]) 83 | value = b1 << 8 | b2 84 | return value, buffer[2:], err 85 | } 86 | 87 | 88 | // TakeLong() returns first four buffer bytes as 32-bit int. 89 | // Returns: 90 | // value - 32-bit 'long' value 91 | // newBuffer - slice of buffer starting after index 4. 92 | // error - non-nil if buffer is not at least 4-bytes long. 93 | // 94 | func TakeLong(buffer []byte) (value int, newBuffer []byte, err error) { 95 | err = requireBufferLength(buffer, 4) 96 | if err != nil { 97 | return 0, []byte{}, err 98 | } 99 | value = 0 100 | for i, shift := 0, 24; i < 4; i, shift = i+1, shift-8 { 101 | n := int(buffer[i]) 102 | value += n << shift 103 | } 104 | return value, buffer[4:], err 105 | } 106 | 107 | // TakeVLQ() returns variable length value from start of buffer. 108 | // The maximum number of bytes consumed is 4. 109 | // Returns: 110 | // vlq - the 'value' 111 | // newBuffer - slice of buffer after final vlq byte. 112 | // error - non-nil if vlq is not terminated after reading 4 bytes. 113 | // 114 | func TakeVLQ(buffer []byte) (vlq *VLQ, newBuffer []byte, err error) { 115 | vlq = new(VLQ) 116 | var maxBytes = 4 117 | var acc = make([]byte, 0, maxBytes) 118 | for i := 0; i < maxBytes; i++ { 119 | if i >= len(buffer) { 120 | errmsg := "expect.TakeVLQ index out of bounds, " 121 | errmsg += "index = %d, buffer length = %d" 122 | err = pigerr.New(fmt.Sprintf(errmsg, i, len(buffer))) 123 | return vlq, []byte{}, err 124 | } 125 | n := buffer[i] 126 | acc = append(acc, n) 127 | if n & 0x80 == 0 { 128 | break 129 | } 130 | } 131 | vlq.setBytes(acc) 132 | return vlq, buffer[len(acc):], err 133 | } 134 | 135 | -------------------------------------------------------------------------------- /smf/take_test.go: -------------------------------------------------------------------------------- 1 | package smf 2 | 3 | import ( 4 | "testing" 5 | "fmt" 6 | ) 7 | 8 | func TestTakeByte(t *testing.T) { 9 | fmt.Print("") 10 | buffer := []byte{1, 2, 3} 11 | value, newBuffer, err := TakeByte(buffer) 12 | if value != byte(1) { 13 | errmsg := "Expected TakeByte return of 1, got %d" 14 | t.Fatalf(errmsg, value) 15 | } 16 | if len(newBuffer) != 2 || newBuffer[0] != 2 || newBuffer[1] != 3 { 17 | errmsg := "TakeByte expected to return new byte buffer [2, 3], got %v" 18 | t.Fatalf(errmsg, newBuffer) 19 | } 20 | if err != nil { 21 | errmsg := "TakeByte returned incorrect error: %s" 22 | t.Fatalf(errmsg, err) 23 | } 24 | 25 | // empty buffer test 26 | buffer = []byte{} 27 | _, _, err = TakeByte(buffer) 28 | if err == nil { 29 | errmsg := "TakeByte did not detect empty buffer" 30 | t.Fatalf(errmsg) 31 | } 32 | } 33 | 34 | 35 | func TestTakeShort(t *testing.T) { 36 | buffer := []byte{1, 2, 3} 37 | value, newBuffer, err := TakeShort(buffer) 38 | expectedValue := int(1<<8 + 2) 39 | if value != expectedValue { 40 | errmsg := "TakeShort expected to return value %d, got %d" 41 | t.Fatalf(errmsg, expectedValue, value) 42 | } 43 | if len(newBuffer) != 1 || newBuffer[0] != 3 { 44 | errmsg := "TakeShort expected to return new byte buffer [3], got %v" 45 | t.Fatalf(errmsg, newBuffer) 46 | } 47 | if err != nil { 48 | errmsg := "TakeShort returned unexpected error: %s" 49 | t.Fatalf(errmsg, err) 50 | } 51 | // empty buffer test 52 | buffer = []byte{1} 53 | _, _, err = TakeShort(buffer) 54 | if err == nil { 55 | errmsg := "TakeShort did not detect empty buffer" 56 | t.Fatalf(errmsg) 57 | } 58 | } 59 | 60 | func TestTakeLong(t *testing.T) { 61 | buffer := []byte{1, 2, 3, 4} 62 | value, newBuffer, err := TakeLong(buffer) 63 | expectedValue := int(1<<24 + 2<<16 + 3<<8 + 4) 64 | if value != expectedValue { 65 | errmsg := "TakeLong expected to return value %d, got %d" 66 | t.Fatalf(errmsg, expectedValue, value) 67 | } 68 | if len(newBuffer) != 0 { 69 | errmsg := "TakeLong expected to return new byte buffer [], got %v" 70 | t.Fatalf(errmsg, newBuffer) 71 | } 72 | if err != nil { 73 | errmsg := "TakeLong returned unexpected error: %s" 74 | t.Fatalf(errmsg, err) 75 | } 76 | // empty buffer test 77 | buffer = []byte{1} 78 | _, _, err = TakeLong(buffer) 79 | if err == nil { 80 | errmsg := "TakeLong did not detect empty buffer" 81 | t.Fatalf(errmsg) 82 | } 83 | } 84 | 85 | 86 | func TestTakeVLQ(t *testing.T) { 87 | // test buffer contains 3-vlq values 88 | // 0x40 --> 0x40 89 | // 0x81, 0x00 --> 0x80 90 | // 0xFF, 0xFF, 0xFF, 0x7F --> 0x0FFFFFFF 91 | // 92 | buffer := []byte{0x40, 0x81, 0x00, 0xFF, 0xFF, 0xFF, 0x7F} 93 | 94 | // pass 1, single byte 95 | vlq, newBuffer, err := TakeVLQ(buffer) 96 | expected := 0x40 97 | if vlq.Value() != expected { 98 | errmsg := "TakeVLQ (pass 1) did note return expected value 0x%2X, got 0x%2X" 99 | t.Fatalf(errmsg, expected, vlq.Value()) 100 | } 101 | if len(newBuffer) != len(buffer)-1 || newBuffer[0] != 0x81 { 102 | errmsg := "TakeVLQ (pass 1) did not return expected newBuffer\n" 103 | errmsg += "Expected %d byte buffer, and buffer[0] = 0x81, got %v" 104 | t.Fatalf(errmsg, len(buffer)-1, buffer) 105 | } 106 | if err != nil { 107 | errmsg := "TakeVLQ (pass 1) returned unexpected error, %s" 108 | t.Fatalf(errmsg, err) 109 | } 110 | 111 | // pass 2, 2 byte 112 | buffer = newBuffer 113 | vlq, newBuffer, err = TakeVLQ(buffer) 114 | expected = 0x80 115 | if vlq.Value() != expected { 116 | errmsg := "TakeVLQ (pass 2) did note return expected value 0x%2X, got 0x%2X" 117 | t.Fatalf(errmsg, expected, vlq.Value()) 118 | } 119 | if len(newBuffer) != 4 || newBuffer[0] != 0xFF { 120 | errmsg := "TakeVLQ (pass 2) did not return expected newBuffer\n" 121 | errmsg += "Expected 4 byte buffer, and buffer[0] = 0xFF, got %v" 122 | t.Fatalf(errmsg, newBuffer) 123 | } 124 | if err != nil { 125 | errmsg := "TakeVLQ (pass 2) returned unexpected error, %s" 126 | t.Fatalf(errmsg, err) 127 | } 128 | 129 | // pass 3, 4-byte 130 | buffer = newBuffer 131 | vlq, newBuffer, err = TakeVLQ(buffer) 132 | expected = 0x0FFFFFFF 133 | if vlq.Value() != expected { 134 | errmsg := "TakeVLQ (pass 3) did note return expected value 0x%2X, got 0x%2X" 135 | t.Fatalf(errmsg, expected, vlq.Value()) 136 | } 137 | if len(newBuffer) != 0 { 138 | errmsg := "TakeVLQ (pass 3) did not return expected newBuffer\n" 139 | errmsg += "Expected empty buffer, got %v" 140 | t.Fatalf(errmsg, newBuffer) 141 | } 142 | if err != nil { 143 | errmsg := "TakeVLQ (pass 3) returned unexpected error, %s" 144 | t.Fatalf(errmsg, err) 145 | } 146 | 147 | buffer = []byte{0xFF, 0xFF, 0xFF} 148 | _, _, err = TakeVLQ(buffer) 149 | if err == nil { 150 | errmsg := "TakeVLQ did not return error for malformed buffer" 151 | t.Fatalf(errmsg) 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /smf/tempo.go: -------------------------------------------------------------------------------- 1 | package smf 2 | 3 | /* 4 | * Defines functions on meta tempo change. 5 | */ 6 | 7 | 8 | import ( 9 | "fmt" 10 | gomidi "gitlab.com/gomidi/midi/v2" 11 | ) 12 | 13 | const ( 14 | TEMPO_CONSTANT float64 = 60000000 15 | MAX_TEMPO float64 = 300 16 | ) 17 | 18 | 19 | // MakeTempoMessage creates new meta tempo-change message. 20 | // tempo argument in BPM. 21 | // Returns non-nil error if tempo is out of bounds. 22 | // 23 | func MakeTempoMessage(tempo float64) (msg gomidi.Message, err error) { 24 | var usec uint64 25 | var d0, d1, d2 byte 26 | if tempo <= 0 || tempo > MAX_TEMPO { 27 | errmsg := "Tempo out of bounds: %f" 28 | err = fmt.Errorf(errmsg, tempo) 29 | return 30 | } 31 | usec = uint64(TEMPO_CONSTANT/tempo) 32 | d0 = byte((usec & 0xFF0000) >> 16) 33 | d1 = byte((usec & 0x00FF00) >> 8) 34 | d2 = byte(usec & 0x0000FF) 35 | msg = gomidi.NewMessage([]byte{0xFF, 0x51, 0x03, d0, d1, d2}) 36 | return 37 | } 38 | 39 | // MetaTempoMicroseconds return micro seconds per quarter note from tempo message. 40 | // The error return is non-nil if message is not a meta tempo-change. 41 | // 42 | func MetaTempoMicroseconds(msg gomidi.Message) (usec uint64, err error) { 43 | d := msg.Data 44 | if len(d) != 6 || d[0] != 0xFF || d[1] != 0x51 { 45 | errmsg := "%v is not a meta tempo message" 46 | err = fmt.Errorf(errmsg, msg) 47 | return usec, err 48 | } 49 | usec = 0 50 | for i, shift := 3, 16; i < 6; i, shift = i+1, shift-8 { 51 | usec += uint64(d[i]) << shift 52 | } 53 | if usec == 0 { 54 | errmsg := "%v is malformed meta tempo message" 55 | err = fmt.Errorf(errmsg, d) 56 | } 57 | return usec, err 58 | } 59 | 60 | // MetaTempBPM returns tempo in BPM for meta tempo-change message. 61 | // The error return is non-nil if the message is not a meta tempo-change. 62 | // 63 | func MetaTempoBPM(msg gomidi.Message) (tempo float64, err error) { 64 | var usec uint64 65 | usec, err = MetaTempoMicroseconds(msg) 66 | if err != nil { 67 | tempo = 60.0 68 | return tempo, err 69 | } 70 | return TEMPO_CONSTANT/float64(usec), err 71 | } 72 | 73 | 74 | // IsTempoChange returns true iff message is a meta temp-change. 75 | // 76 | func IsTempoChange(msg gomidi.Message) bool { 77 | d := msg.Data 78 | return len(d) == 6 && d[0] == 0xFF && d[1] == 0x51 79 | } 80 | 81 | -------------------------------------------------------------------------------- /smf/tempo_test.go: -------------------------------------------------------------------------------- 1 | package smf 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | gomidi "gitlab.com/gomidi/midi/v2" 7 | ) 8 | 9 | 10 | func TestTempo(t *testing.T) { 11 | var t60 gomidi.Message 12 | var err error 13 | var bpm float64 14 | t60, err = MakeTempoMessage(60.0) 15 | if err != nil { 16 | t.Fatalf(err.Error()) 17 | } 18 | bpm, err = MetaTempoBPM(t60) 19 | if err != nil { 20 | t.Fatalf(err.Error()) 21 | } 22 | if bpm != 60.0 { 23 | errmsg := "Expected tempo of 60 BPM, got %f" 24 | err = fmt.Errorf(errmsg, bpm) 25 | t.Fatalf(err.Error()) 26 | } 27 | _, err = MakeTempoMessage(-1.0) 28 | if err == nil { 29 | errmsg := "Did not detect negative tempo" 30 | t.Fatalf(errmsg) 31 | } 32 | _, err = MakeTempoMessage(MAX_TEMPO+1) 33 | if err == nil { 34 | errmsg := "Did not detect out of bounds tempo" 35 | t.Fatalf(errmsg) 36 | } 37 | } 38 | 39 | -------------------------------------------------------------------------------- /smf/text.go: -------------------------------------------------------------------------------- 1 | package smf 2 | 3 | 4 | /* 5 | * Defines functions for meta text messages. 6 | */ 7 | 8 | import ( 9 | "fmt" 10 | "strings" 11 | gomidi "gitlab.com/gomidi/midi/v2" 12 | ) 13 | 14 | // isTextType returns true iff textType is a meta text type byte. 15 | // 16 | func isTextType(textType byte) bool { 17 | return 0x01 <= textType && textType <= 0x07 18 | } 19 | 20 | // IsTextMessage returns true if message is a meta text message. 21 | // All text bearing messages return true. 22 | // 23 | func IsTextMessage(msg gomidi.Message) bool { 24 | d := msg.Data 25 | return len(d) > 3 && d[0] == 0xFF && isTextType(d[1]) 26 | } 27 | 28 | // MakeTextMessage creates a new meta message bearing text. 29 | // args: 30 | // textType must be one of: 31 | // 0x01 text 32 | // 0x02 copyright 33 | // 0x03 track name 34 | // 0x04 instrument name 35 | // 0x05 lyric 36 | // 0x06 marker 37 | // 0x07 cuepoint 38 | // 39 | func MakeTextMessage(textType byte, text string) (msg gomidi.Message, err error) { 40 | if !isTextType(textType) { 41 | errmsg := "0x%02X is not a valid Meta Text type" 42 | err = fmt.Errorf(errmsg, textType) 43 | return 44 | } 45 | vlq := NewVLQ(len(text)) 46 | data := make([]byte, 2, 2 + len(text) + vlq.Length()) 47 | data[0] = 0xFF 48 | data[1] = textType 49 | for _, b := range vlq.Bytes() { 50 | data = append(data, b) 51 | } 52 | for _, b := range []byte(text) { 53 | data = append(data, b) 54 | } 55 | msg = gomidi.NewMessage(data) 56 | return 57 | } 58 | 59 | 60 | // ExtractMetaText returns text contents of meta text message. 61 | // 62 | func ExtractMetaText(msg gomidi.Message) (text string, txType byte, err error) { 63 | if !IsTextMessage(msg) { 64 | errmsg := "Expected meta text message, got %v" 65 | err = fmt.Errorf(errmsg, err) 66 | return 67 | } 68 | var start int 69 | _, start, err = ExpectVLQ(msg.Data, 2) 70 | if err != nil { 71 | errmsg := "Could not read vlq for meta text message: %v\n%s" 72 | err = fmt.Errorf(errmsg, msg, err) 73 | return 74 | } 75 | d := msg.Data 76 | text = string(d[start: len(d)]) 77 | txType = d[1] 78 | return 79 | } 80 | 81 | 82 | func SplitTime(seconds float64) (hr int, min int, sec int, fsec float64) { 83 | hr = int(seconds / 3600) 84 | seconds -= float64(hr * 3600) 85 | min = int(seconds / 60) 86 | seconds -= float64(min * 60) 87 | sec = int(seconds) 88 | fsec = float64(sec) - seconds 89 | 90 | return hr, min, sec, fsec 91 | } 92 | 93 | func FormatTime(seconds float64) string { 94 | hr, min, sec, fsec := SplitTime(seconds) 95 | f := fmt.Sprintf("%f", fsec) 96 | pos := strings.Index(f, ".") 97 | return fmt.Sprintf("%02d:%02d:%02d%s", hr, min, sec, f[pos:]) 98 | } 99 | -------------------------------------------------------------------------------- /smf/text_test.go: -------------------------------------------------------------------------------- 1 | package smf 2 | 3 | import ( 4 | // "fmt" 5 | "testing" 6 | gomidi "gitlab.com/gomidi/midi/v2" 7 | ) 8 | 9 | 10 | func TestIsTextType(t *testing.T) { 11 | if isTextType(0x00) || isTextType(0x08) { 12 | errmsg := "isTextType returns false positive for invalid meta type." 13 | t.Fatalf(errmsg) 14 | } 15 | if !isTextType(0x03) { 16 | errmsg := "expected true for isTextType(0x03), got false" 17 | t.Fatalf(errmsg) 18 | } 19 | } 20 | 21 | func TestIsTextMessage(t *testing.T) { 22 | good := gomidi.NewMessage([]byte{0xFF, 0x01, 0x03, 0x41, 0x42, 0x43}) 23 | bad1 := gomidi.NewMessage([]byte{0x80, 0x00, 0x00}) 24 | bad2 := gomidi.NewMessage([]byte{0xFF, 0x2F, 0x00}) 25 | if !IsTextMessage(good) { 26 | errmsg := "IsTextMessage returnd false for meta text message" 27 | t.Fatalf(errmsg) 28 | } 29 | if IsTextMessage(bad1) { 30 | errmsg := "IsTextMessage returned true for non-meta message." 31 | t.Fatalf(errmsg) 32 | } 33 | if IsTextMessage(bad2) { 34 | errmsg := "IsTextMessage returned true for non-text meta message." 35 | t.Fatalf(errmsg) 36 | } 37 | } 38 | 39 | func TestText(t *testing.T) { 40 | msg, _ := MakeTextMessage(0x02, "ABC") 41 | text, txType, err := ExtractMetaText(msg) 42 | if err != nil { 43 | errmsg := "Text returned unexpected error: %s" 44 | t.Fatalf(errmsg, err) 45 | } 46 | if text != "ABC" { 47 | errmsg := "Expected text \"%s\", got \"%s\"" 48 | t.Fatalf(errmsg, "ABC", text) 49 | } 50 | if txType != 0x02 { 51 | errmsg := "Expected text type 0x02, got 0x%02X" 52 | t.Fatalf(errmsg, txType) 53 | } 54 | } 55 | 56 | // func TestSplitTime(t *testing.T) { 57 | // fmt.Println() 58 | // fmt.Println(FormatTime(3600.0 + 72.123)) 59 | // } 60 | -------------------------------------------------------------------------------- /smf/track.go: -------------------------------------------------------------------------------- 1 | package smf 2 | 3 | /* 4 | ** track.go defines SMF track chunks. 5 | ** 6 | */ 7 | 8 | import ( 9 | "fmt" 10 | "os" 11 | "github.com/plewto/pigiron/midi" 12 | gomidi "gitlab.com/gomidi/midi/v2" 13 | ) 14 | 15 | var trackID chunkID = [4]byte{0x4d, 0x54, 0x72, 0x6B} 16 | 17 | type Track struct { 18 | events []Event 19 | } 20 | 21 | func (trk *Track) ID() chunkID { 22 | return trackID 23 | } 24 | 25 | func (trk *Track) Length() int { 26 | acc := 0 27 | for _, ev := range trk.events { 28 | acc += ev.Length() 29 | } 30 | return acc 31 | } 32 | 33 | func (trk *Track) String() string { 34 | return fmt.Sprintf("Track (%d events)", len(trk.events)) 35 | } 36 | 37 | func (trk *Track) Events() []Event { 38 | return trk.events 39 | } 40 | 41 | 42 | func readTrack(f *os.File) (track *Track, err error) { 43 | var id chunkID 44 | var length int 45 | id, length, err = readChunkPreamble(f) 46 | if err != nil { 47 | return 48 | } 49 | if !id.eq(trackID) { 50 | msg := "Expected track id '%s', got '%s'" 51 | err = fmt.Errorf(msg, trackID, id) 52 | return 53 | } 54 | var bytes = make([]byte, length) 55 | var readCount = 0 56 | readCount, err = f.Read(bytes) 57 | if readCount != length { 58 | msg := "Expected %d track bytes, read %d" 59 | err = fmt.Errorf(msg, length, readCount) 60 | return 61 | } 62 | if err != nil { 63 | msg := "smf.readTrack could not read track\n" 64 | msg += fmt.Sprintf("%s", err) 65 | err = fmt.Errorf(msg) 66 | return 67 | } 68 | track = new(Track) 69 | var index int 70 | index, err = track.convertEvents(bytes) 71 | if err != nil { 72 | errmsg := fmt.Sprintf("Error while converting smf track bytes to events. index = %d", index) 73 | err = fmt.Errorf("%s\n%s", errmsg, err) 74 | } 75 | return 76 | } 77 | 78 | 79 | 80 | func (trk *Track) convertEvents(buffer []byte) (index int, err error) { 81 | var acc = make([]Event, 0, 1024) 82 | var runningStatus = midi.StatusByte(0) 83 | index = 0 84 | for index < len(buffer) { 85 | var vlq *VLQ 86 | vlq, index, err = ExpectVLQ(buffer, index) 87 | if err != nil { 88 | return 89 | } 90 | var deltaTime = uint64(vlq.Value()) 91 | var b = buffer[index] 92 | var msgBytes []byte 93 | if b > 0x7F { // new statys byte 94 | var st = midi.StatusByte(b) 95 | switch { 96 | case midi.IsChannelStatus(st): 97 | runningStatus = st 98 | msgBytes, index, err = ExpectChannelMessage(buffer, b, index) 99 | case b == byte(midi.SYSEX): 100 | runningStatus = midi.StatusByte(0) 101 | msgBytes, index, err = ExpectSysexMessage(buffer, index) 102 | case midi.IsSystemRealtimeStatus(st): 103 | runningStatus = midi.StatusByte(0) 104 | msgBytes, index, err = ExpectSystemMessage(buffer, index) 105 | case midi.IsMetaStatus(st): 106 | runningStatus = midi.StatusByte(0) 107 | msgBytes, index, err = ExpectMetaMessage(buffer, index) 108 | if err == nil && msgBytes[1] == byte(midi.META_END_OF_TRACK) { 109 | break 110 | } 111 | case runningStatus != 0: 112 | msgBytes, index, err = ExpectRunningStatus(buffer, byte(runningStatus), index) 113 | default: 114 | errmsg := "smf.Track.convertEvents switch default.\n" 115 | errmsg += "This should never happen, buffer index was %d" 116 | err = fmt.Errorf(errmsg, index) 117 | } 118 | } else { // assume running status 119 | if runningStatus == 0 { 120 | errmsg := "Expected running status at index %d" 121 | err = fmt.Errorf(errmsg, index) 122 | return 123 | } 124 | msgBytes, index, err = ExpectRunningStatus(buffer, byte(runningStatus), index) 125 | } 126 | if err != nil { 127 | return 128 | } 129 | acc = append(acc, Event{deltaTime, gomidi.NewMessage(msgBytes)}) 130 | } 131 | events := make([]Event, len(acc), len(acc)) 132 | for i, e := range acc { 133 | events[i] = e 134 | } 135 | trk.events = events 136 | return 137 | } 138 | 139 | func (trk *Track) Dump() string { 140 | var acc = fmt.Sprintf("Track %d events\n", len(trk.events)) 141 | var time = uint64(0) 142 | for i, evnt := range trk.events { 143 | acc += fmt.Sprintf("[%4d] t %8d %s\n", i, time, evnt.String()) 144 | time += evnt.deltaTime 145 | } 146 | return acc 147 | } 148 | -------------------------------------------------------------------------------- /smf/track_test.go: -------------------------------------------------------------------------------- 1 | package smf 2 | 3 | import ( 4 | "testing" 5 | "fmt" 6 | ) 7 | 8 | var ( 9 | rawBytes = []byte { 10 | 0x00, 0xFF, 0x00, 0x02, 0x00, 0x01, // [ 0] track number 0 11 | 0x00, 0xFF, 0x51, 0x03, 0x07, 0xA1, 0x20, // [ 6] tempo 120 BPM 12 | 0x00, 0xFF, 0x03, 0x04, 0x54, 0x65, 0x73, 0x74,// [ 13] track name "Test" 13 | 0x00, 0x90, 0x3c, 0x40, // [ 21] note on 14 | 0x01, 0x3d, 0x40, // [ 25] note on running status 15 | 0x02, 0x3c, 0x00, // [ 28] note off running status 16 | 0x03, 0x3d, 0x00, // [ 31] ntoe off running status 17 | 0x00, 0xF8, // [ 34] clock 18 | 0x00, 0xF0, 0x00, 0x01, 0xF7, // [ 36] sysex 19 | 0x01, 0x90, 0x3c, 0x7f, // [ 41] note on 20 | 0x01, 0xFF, 0x2F, 0x00, // [ 45] end of track 21 | 22 | } 23 | ) 24 | 25 | 26 | func TestTrackEventConversion(t *testing.T) { 27 | fmt.Println("TestTrackEventConversion") 28 | trk := new(Track) 29 | _, err := trk.convertEvents(rawBytes) 30 | if err != nil { 31 | t.Fatalf("%s", err) 32 | } 33 | } 34 | 35 | -------------------------------------------------------------------------------- /smf/vlq.go: -------------------------------------------------------------------------------- 1 | package smf 2 | 3 | /* 4 | * Defines MIDI variable-length quantity. 5 | * http://midi.teragonaudio.com/tech/midifile/vari.htm 6 | * 7 | */ 8 | 9 | import "fmt" 10 | 11 | // VLQ implements MIDI file variable length quantity. 12 | // 13 | type VLQ struct { 14 | bytes []byte 15 | } 16 | 17 | 18 | func (vlq *VLQ) setBytes(bytes []byte) { 19 | vlq.bytes = bytes 20 | } 21 | 22 | // vlq.Bytes() returns byte slice equivalent of the vlq. 23 | // 24 | func (vlq *VLQ) Bytes() []byte { 25 | return vlq.bytes 26 | } 27 | 28 | // vlg.SetValue() sets the vlq value. 29 | // 30 | func (vlq *VLQ) SetValue(n int) { 31 | mask := 0x7f 32 | acc := make([]byte, 0, 4) 33 | acc = append(acc, byte(n & mask)) 34 | n = n >> 7 35 | for n > 0 { 36 | acc = append(acc, byte(0x80 |(n & mask))) 37 | n = n >> 7 38 | } 39 | vlq.bytes = make([]byte, len(acc)) 40 | vlq.bytes = reverse(acc) 41 | } 42 | 43 | // vlq.Value() returns the vlq's value. 44 | // 45 | func (vlq *VLQ) Value() int { 46 | acc := 0 47 | scale := 1 48 | for _, b := range reverse(vlq.bytes) { 49 | acc = acc + scale * int(b & 0x7f) 50 | scale *= 128 51 | } 52 | return acc 53 | } 54 | 55 | func (vlq *VLQ) String() string { 56 | s := "VLQ [ " 57 | for _, b := range vlq.bytes { 58 | s += fmt.Sprintf("%02x ", b) 59 | } 60 | s += fmt.Sprintf("] Value 0x%x", vlq.Value()) 61 | return s 62 | } 63 | 64 | // vlq.Length() returns the vlq byte-count. 65 | // 66 | func (vlq *VLQ) Length() int { 67 | return len(vlq.bytes) 68 | } 69 | 70 | 71 | // NewVLQ() creates new vlq with given value. 72 | // 73 | func NewVLQ(value int) *VLQ { 74 | vlq := &VLQ{} 75 | vlq.SetValue(value) 76 | return vlq 77 | } 78 | 79 | -------------------------------------------------------------------------------- /smf/vlq_test.go: -------------------------------------------------------------------------------- 1 | package smf 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | ) 7 | 8 | // compare 2 byte arrays 9 | // 10 | func cmp(a []byte, b []byte) bool { 11 | if len(a) != len(b) { 12 | return false 13 | } 14 | for i, s := range a { 15 | t := b[i] 16 | if s != t { 17 | return false 18 | } 19 | } 20 | return true 21 | } 22 | 23 | // hex format byte array 24 | // 25 | func xformat(a []byte) string { 26 | acc := "{ " 27 | for i, v := range a { 28 | acc += fmt.Sprintf("0x%02X", v) 29 | if i == len(a) - 1 { 30 | acc += " }" 31 | } else { 32 | acc += ", " 33 | } 34 | } 35 | return acc 36 | } 37 | 38 | func TestVLQ(t *testing.T) { 39 | fmt.Println("TestVLQ") 40 | cases := make(map[int][]byte) 41 | cases[0x00000000] = []byte{0x00} 42 | cases[0x00000040] = []byte{0x40} 43 | cases[0x0000007F] = []byte{0x7F} 44 | cases[0x00000080] = []byte{0x81, 0x00} 45 | cases[0x00002000] = []byte{0xC0, 0x00} 46 | cases[0x00003FFF] = []byte{0xFF, 0x7F} 47 | cases[0x00004000] = []byte{0x81, 0x80, 0x00} 48 | cases[0x00100000] = []byte{0xC0, 0x80, 0x00} 49 | cases[0x001FFFFF] = []byte{0xFF, 0xFF, 0x7F} 50 | cases[0x00200000] = []byte{0x81, 0x80, 0x80, 0x00} 51 | cases[0x08000000] = []byte{0xC0, 0x80, 0x80, 0x00} 52 | cases[0x0FFFFFFF] = []byte{0xFF, 0xFF, 0xFF, 0x7F} 53 | 54 | for value, expect := range cases { 55 | vlq := NewVLQ(value) 56 | if !cmp(expect, vlq.Bytes()) { 57 | msg := "For VLQ value 0x%08X, expected bytes %s, got %s" 58 | t.Fatal(msg, value, expect, vlq.Bytes()) 59 | } 60 | } 61 | 62 | for expect, bytes := range cases { 63 | vlq := NewVLQ(0) 64 | vlq.setBytes(bytes) 65 | if expect != vlq.Value() { 66 | msg := "For VLQ bytes %s, expected value 0x%08X, got 0x%08X" 67 | t.Fatal(msg, xformat(bytes), expect, vlq.Value()) 68 | } 69 | } 70 | } 71 | 72 | -------------------------------------------------------------------------------- /version.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "fmt" 4 | 5 | type version struct { 6 | major int 7 | minor int 8 | revision int 9 | level string 10 | } 11 | 12 | var VERSION = version{0, 2, 0, "Beta"} 13 | 14 | func (v *version) String() string { 15 | mj, mn, rv, lev := v.major, v.minor, v.revision, v.level 16 | return fmt.Sprintf("Version %d.%d.%d %s", mj, mn, rv, lev) 17 | } 18 | 19 | --------------------------------------------------------------------------------