diff --git a/.gitignore b/.gitignore index 66fd13c..837448b 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,5 @@ # Dependency directories (remove the comment below to include it) # vendor/ + +*.txt \ No newline at end of file diff --git a/README.md b/README.md index e93da4d..7bdf580 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,48 @@ -# vban-cli -VBAN cli utility for sending parameter requests +# vbantxt + +VBAN sendtext cli utility for sending Voicemeeter string requests over a network. + +## Tested against + +- Basic 1.0.8.4 +- Banana 2.0.6.4 +- Potato 3.0.2.4 + +## Requirements + +- [Voicemeeter](https://voicemeeter.com/) +- Go 1.18 or greater + +## `Use` + +#### Command Line + +Pass `host`, `port` and `streamname` as flags, for example: + +`vbantxt-cli -h="gamepc.local" -p=6980 -s=Command1 "strip[0].mute=1 strip[1].mono=1"` + +You may also store them in a `config.toml` located in `home directory / .vbantxt_cli /` + +A valid `config.toml` might look like this: + +```toml +[connection] +Host="gamepc.local" +Port=6990 +Streamname="Command1" +``` + +#### Script files + +The vbantxt-cli utility accepts a single string request or an array of string requests. This means you can pass scripts stored in files. + +For example, in Windows with Powershell you could: + +`vbantxt-cli $(Get-Content .\script.txt)` + +to load commands from a file: + +``` +strip[0].mute=0;strip[0].mute=0 +strip[1].mono=0;strip[1].mono=0 +``` diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..99c4ec3 --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module github.com/onyx-and-iris/vbantxt-cli + +go 1.19 + +require ( + github.com/BurntSushi/toml v1.2.1 + github.com/sirupsen/logrus v1.9.0 +) + +require golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e63a134 --- /dev/null +++ b/go.sum @@ -0,0 +1,17 @@ +github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak= +github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0= +github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..73e3aaa --- /dev/null +++ b/main.go @@ -0,0 +1,155 @@ +package main + +import ( + "bytes" + "encoding/binary" + "errors" + "flag" + "fmt" + "net" + "os" + "path/filepath" + "time" + + "github.com/BurntSushi/toml" + + log "github.com/sirupsen/logrus" +) + +var ( + host string + port int + streamname string + bps int + channel int + delay int + loglevel int + + bpsOpts = []int{0, 110, 150, 300, 600, 1200, 2400, 4800, 9600, 14400, 19200, 31250, + 38400, 57600, 115200, 128000, 230400, 250000, 256000, 460800, 921600, + 1000000, 1500000, 2000000, 3000000} +) + +type ( + // connection represents the configurable fields of a config.toml + connection struct { + Host string + Port int + Streamname string + } + + // config maps toml headers + config struct { + Connection map[string]connection + } +) + +func setLogLevel(loglevel int) { + switch loglevel { + case 0: + log.SetLevel(log.WarnLevel) + case 1: + log.SetLevel(log.InfoLevel) + case 2: + log.SetLevel(log.DebugLevel) + } +} + +func main() { + flag.StringVar(&host, "host", "", "vban host") + flag.StringVar(&host, "h", "", "vban host (shorthand)") + flag.IntVar(&port, "port", 6980, "vban server port") + flag.IntVar(&port, "p", 6980, "vban server port (shorthand)") + flag.StringVar(&streamname, "streamname", "Command1", "stream name for text requests") + flag.StringVar(&streamname, "s", "Command1", "stream name for text requests (shorthand)") + flag.IntVar(&bps, "bps", 0, "vban bps") + flag.IntVar(&bps, "b", 0, "vban bps (shorthand)") + flag.IntVar(&channel, "channel", 0, "vban channel") + flag.IntVar(&channel, "c", 0, "vban channel (shorthand)") + flag.IntVar(&delay, "delay", 20, "delay between requests") + flag.IntVar(&delay, "d", 20, "delay between requests (shorthand)") + flag.IntVar(&loglevel, "loglevel", 0, "log level") + flag.IntVar(&loglevel, "l", 0, "log level (shorthand)") + flag.Parse() + + setLogLevel(loglevel) + + c, err := vbanConnect() + if err != nil { + log.Fatal(err) + } + defer c.Close() + + header := newRequestHeader(streamname, indexOf(bpsOpts, bps), channel) + for _, arg := range flag.Args() { + err := send(c, header, arg) + if err != nil { + log.Error(err) + } + } +} + +// vbanConnect establishes a VBAN connection to remote host +func vbanConnect() (*net.UDPConn, error) { + if host == "" { + conn, err := connFromToml() + if err != nil { + return nil, err + } + host = conn.Host + port = conn.Port + streamname = conn.Streamname + if host == "" { + err := errors.New("must provide a host with --host flag or config.toml") + return nil, err + } + } + CONNECT := fmt.Sprintf("%s:%d", host, port) + + s, _ := net.ResolveUDPAddr("udp4", CONNECT) + c, err := net.DialUDP("udp4", nil, s) + if err != nil { + return nil, err + } + log.Info("Connected to ", c.RemoteAddr().String()) + + return c, nil +} + +// connFromToml parses connection info from config.toml +func connFromToml() (*connection, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + log.Fatal(err) + } + f := filepath.Join(homeDir, ".vbantxt_cli", "config.toml") + if _, err := os.Stat(f); err != nil { + err := fmt.Errorf("unable to locate %s", f) + return nil, err + } + + var c config + _, err = toml.DecodeFile(f, &c.Connection) + if err != nil { + return nil, err + } + conn := c.Connection["connection"] + return &conn, nil +} + +// send sends a VBAN text request over UDP to remote host +func send(c *net.UDPConn, h *requestHeader, msg string) error { + log.Debug("Sending '", msg, "' to: ", c.RemoteAddr().String()) + data := []byte(msg) + _, err := c.Write(append(h.header(), data...)) + if err != nil { + return err + } + var a uint32 + _ = binary.Read(bytes.NewReader(h.framecounter[:]), binary.LittleEndian, &a) + binary.LittleEndian.PutUint32(h.framecounter[:], a+1) + + time.Sleep(time.Duration(delay) * time.Millisecond) + + return nil +} diff --git a/packet.go b/packet.go new file mode 100644 index 0000000..b25a54f --- /dev/null +++ b/packet.go @@ -0,0 +1,50 @@ +package main + +var r *requestHeader + +const VBAN_PROTOCOL_TXT = 0x40 + +// requestHeader represents a single request header +type requestHeader struct { + name string + bpsIndex int + channel int + framecounter []byte +} + +// newRequestHeader returns a pointer to a requestHeader struct as a singleton +func newRequestHeader(streamname string, bpsI, channel int) *requestHeader { + if r != nil { + return r + } + return &requestHeader{streamname, bpsI, channel, make([]byte, 4)} +} + +// sr defines the samplerate for the request +func (r *requestHeader) sr() byte { + return byte(VBAN_PROTOCOL_TXT + r.bpsIndex) +} + +// nbc defines the channel of the request +func (r *requestHeader) nbc() byte { + return byte(r.channel) +} + +// streamname defines the stream name of the text request +func (r *requestHeader) streamname() []byte { + b := make([]byte, 16) + copy(b, r.name) + return b +} + +// header returns a fully formed text request packet header +func (t *requestHeader) header() []byte { + h := []byte("VBAN") + h = append(h, t.sr()) + h = append(h, byte(0)) + h = append(h, t.nbc()) + h = append(h, byte(0x10)) + h = append(h, t.streamname()...) + h = append(h, t.framecounter...) + return h +} diff --git a/util.go b/util.go new file mode 100644 index 0000000..9282e1b --- /dev/null +++ b/util.go @@ -0,0 +1,11 @@ +package main + +// indexOf returns the index of an element in an array +func indexOf[T comparable](collection []T, e T) int { + for i, x := range collection { + if x == e { + return i + } + } + return -1 +}