19 Commits

Author SHA1 Message Date
0c67887ad7 upd README 2026-02-06 17:31:50 +00:00
4db173154b snapshot commands implemented 2026-02-06 17:31:41 +00:00
27922d41bb --loglevel flag added to the root command 2026-02-06 17:31:36 +00:00
e21fd59564 reword 2026-02-06 15:30:18 +00:00
2d829e28e1 upd README 2026-02-06 11:28:52 +00:00
3f8861ded2 timeout now a flag on the root command.
It applies to all messages sent

new option function WithTimeout added
2026-02-06 11:28:40 +00:00
1ad214ba4a add validation to --kind flag 2026-02-06 00:52:12 +00:00
66da965edd fix default value for kind 2026-02-06 00:47:34 +00:00
079a0b240d ensure all optional args are marked as optional 2026-02-06 00:41:26 +00:00
fe711f79f1 Main struct now uses address map 2026-02-06 00:41:06 +00:00
c94ac62cb8 upd address maps 2026-02-06 00:40:52 +00:00
5933b25114 move parser interface into engine.go
add snapshot field to Client
2026-02-06 00:40:42 +00:00
fa704832d5 reword 2026-02-05 21:44:39 +00:00
69925021af spell fix 2026-02-05 15:43:19 +00:00
e092ed3c4e add Configuration section to README 2026-02-05 15:42:39 +00:00
d87bc2678c add short Config flags 2026-02-05 15:42:07 +00:00
c3221f3df5 reduce default timeout to 100ms
add env var for raw timeout
2026-02-05 15:13:33 +00:00
dc733ba500 add macos target to Taskfile 2026-02-05 14:20:03 +00:00
2a5e0e022f upd README 2026-02-05 13:42:57 +00:00
20 changed files with 426 additions and 148 deletions

View File

@@ -6,6 +6,28 @@
go install github.com/onyx-and-iris/xair-cli@latest
```
### Configuration
#### Flags
- --host/-H: Host of the mixer.
- --port/-P: Port of the mixer.
- --kind/-k: The kind of mixer. May be one of (*xair*, *x32*).
- Use this flag to connect to an x32 mixer.
#### Environment Variables
Example .envrc:
```bash
#!/usr/bin/env bash
XAIR_CLI_HOST=mixer.local
XAIR_CLI_PORT=10024
XAIR_CLI_KIND=xair
XAIR_CLI_TIMEOUT=100ms
```
### Use
```console
@@ -15,10 +37,12 @@ A CLI to control Behringer X-Air mixers.
Flags:
-h, --help Show context-sensitive help.
--host="mixer.local" The host of the X-Air device ($XAIR_CLI_HOST).
--port=10024 The port of the X-Air device ($XAIR_CLI_PORT).
--kind="xr18" The kind of the X-Air device ($XAIR_CLI_KIND).
-v, --version Print gobs-cli version information and quit
-H, --host="mixer.local" The host of the X-Air device ($XAIR_CLI_HOST).
-P, --port=10024 The port of the X-Air device ($XAIR_CLI_PORT).
-K, --kind="xair" The kind of the X-Air device ($XAIR_CLI_KIND).
-T, --timeout=100ms Timeout for OSC operations ($XAIR_CLI_TIMEOUT).
-L, --loglevel="warn" Log level for the CLI ($XAIR_CLI_LOGLEVEL).
-v, --version Print xair-cli version information and quit
Commands:
completion (c) Generate shell completion scripts.
@@ -29,8 +53,8 @@ Raw
Main
main mute Get or set the mute state of the Main L/R output.
main fader Get or set the fader level of the Main L/R output.
main fadein Get or set the fade-in time of the Main L/R output.
main fadeout Get or set the fade-out time of the Main L/R output.
main fadein Fade in the Main L/R output over a specified duration.
main fadeout Fade out the Main L/R output over a specified duration.
Strip
strip <index> mute Get or set the mute state of the strip.
@@ -87,6 +111,13 @@ Headamp
headamp <index> gain Get or set the gain of the headamp.
headamp <index> phantom Get or set the phantom power state of the headamp.
Snapshot
snapshot list List all snapshots.
snapshot <index> name Get or set the name of a snapshot.
snapshot <index> save Save the current mixer state.
snapshot <index> load Load a mixer state.
snapshot <index> delete Delete a snapshot.
Run "xair-cli <command> --help" for more information on a command.
```

View File

@@ -9,6 +9,7 @@ vars:
WINDOWS: '{{.BIN_DIR}}/{{.PROGRAM}}_windows_amd64.exe'
LINUX: '{{.BIN_DIR}}/{{.PROGRAM}}_linux_amd64'
MACOS: '{{.BIN_DIR}}/{{.PROGRAM}}_darwin_amd64'
tasks:
default:
@@ -22,6 +23,7 @@ tasks:
cmds:
- task: build-windows
- task: build-linux
- task: build-macos
vet:
desc: Vet the code
@@ -46,6 +48,12 @@ tasks:
- GOOS=linux GOARCH=amd64 go build -o {{.LINUX}} -ldflags="-X main.version={{.VERSION}}"
internal: true
build-macos:
desc: Build the xair-cli project for macOS
cmds:
- GOOS=darwin GOARCH=amd64 go build -o {{.MACOS}} -ldflags="-X main.version={{.VERSION}}"
internal: true
test:
desc: Run tests
cmds:

22
bus.go
View File

@@ -148,7 +148,7 @@ func (cmd *BusFadeoutCmd) Run(ctx *context, bus *BusCmdGroup) error {
// BusNameCmd defines the command for getting or setting the name of a bus.
type BusNameCmd struct {
Name *string `arg:"" help:"The name to set for the bus. If not provided, the current name will be returned."`
Name *string `arg:"" help:"The name to set for the bus. If not provided, the current name will be returned." optional:""`
}
// Run executes the BusNameCmd command, either retrieving the current name of the bus or setting it based on the provided argument.
@@ -238,7 +238,7 @@ func (cmd *BusEqModeCmd) Run(ctx *context, bus *BusCmdGroup) error {
// BusEqBandGainCmd defines the command for getting or setting the gain of a specific EQ band of a bus.
type BusEqBandGainCmd struct {
Gain *float64 `arg:"" help:"The gain to set for the EQ band (in dB). If not provided, the current gain will be returned."`
Gain *float64 `arg:"" help:"The gain to set for the EQ band (in dB). If not provided, the current gain will be returned." optional:""`
}
// Run executes the BusEqBandGainCmd command, either retrieving the current gain of the specified EQ band of the bus or setting it based on the provided argument.
@@ -261,7 +261,7 @@ func (cmd *BusEqBandGainCmd) Run(ctx *context, bus *BusCmdGroup, busEq *BusEqCmd
// BusEqBandFreqCmd defines the command for getting or setting the frequency of a specific EQ band of a bus.
type BusEqBandFreqCmd struct {
Freq *float64 `arg:"" help:"The frequency to set for the EQ band (in Hz). If not provided, the current frequency will be returned."`
Freq *float64 `arg:"" help:"The frequency to set for the EQ band (in Hz). If not provided, the current frequency will be returned." optional:""`
}
// Run executes the BusEqBandFreqCmd command, either retrieving the current frequency of the specified EQ band of the bus or setting it based on the provided argument.
@@ -284,7 +284,7 @@ func (cmd *BusEqBandFreqCmd) Run(ctx *context, bus *BusCmdGroup, busEq *BusEqCmd
// BusEqBandQCmd defines the command for getting or setting the Q factor of a specific EQ band of a bus.
type BusEqBandQCmd struct {
Q *float64 `arg:"" help:"The Q factor to set for the EQ band. If not provided, the current Q factor will be returned."`
Q *float64 `arg:"" help:"The Q factor to set for the EQ band. If not provided, the current Q factor will be returned." optional:""`
}
// Run executes the BusEqBandQCmd command, either retrieving the current Q factor of the specified EQ band of the bus or setting it based on the provided argument.
@@ -389,7 +389,7 @@ func (cmd *BusCompModeCmd) Run(ctx *context, bus *BusCmdGroup) error {
// BusCompThresholdCmd defines the command for getting or setting the compressor threshold of a bus.
type BusCompThresholdCmd struct {
Threshold *float64 `arg:"" help:"The compressor threshold to set (in dB). If not provided, the current compressor threshold will be returned."`
Threshold *float64 `arg:"" help:"The compressor threshold to set (in dB). If not provided, the current compressor threshold will be returned." optional:""`
}
// Run executes the BusCompThresholdCmd command, either retrieving the current compressor threshold of the bus or setting it based on the provided argument.
@@ -412,7 +412,7 @@ func (cmd *BusCompThresholdCmd) Run(ctx *context, bus *BusCmdGroup) error {
// BusCompRatioCmd defines the command for getting or setting the compressor ratio of a bus.
type BusCompRatioCmd struct {
Ratio *float64 `arg:"" help:"The compressor ratio to set. If not provided, the current compressor ratio will be returned."`
Ratio *float64 `arg:"" help:"The compressor ratio to set. If not provided, the current compressor ratio will be returned." optional:""`
}
// Run executes the BusCompRatioCmd command, either retrieving the current compressor ratio of the bus or setting it based on the provided argument.
@@ -435,7 +435,7 @@ func (cmd *BusCompRatioCmd) Run(ctx *context, bus *BusCmdGroup) error {
// BusCompMixCmd defines the command for getting or setting the compressor mix level of a bus.
type BusCompMixCmd struct {
Mix *float64 `arg:"" help:"The compressor mix level to set (in %). If not provided, the current compressor mix level will be returned."`
Mix *float64 `arg:"" help:"The compressor mix level to set (in %). If not provided, the current compressor mix level will be returned." optional:""`
}
// Run executes the BusCompMixCmd command, either retrieving the current compressor mix level of the bus or setting it based on the provided argument.
@@ -458,7 +458,7 @@ func (cmd *BusCompMixCmd) Run(ctx *context, bus *BusCmdGroup) error {
// BusCompMakeupCmd defines the command for getting or setting the compressor makeup gain of a bus.
type BusCompMakeupCmd struct {
Makeup *float64 `arg:"" help:"The compressor makeup gain to set (in dB). If not provided, the current compressor makeup gain will be returned."`
Makeup *float64 `arg:"" help:"The compressor makeup gain to set (in dB). If not provided, the current compressor makeup gain will be returned." optional:""`
}
// Run executes the BusCompMakeupCmd command, either retrieving the current compressor makeup gain of the bus or setting it based on the provided argument.
@@ -481,7 +481,7 @@ func (cmd *BusCompMakeupCmd) Run(ctx *context, bus *BusCmdGroup) error {
// BusCompAttackCmd defines the command for getting or setting the compressor attack time of a bus.
type BusCompAttackCmd struct {
Attack *float64 `arg:"" help:"The compressor attack time to set (in ms). If not provided, the current compressor attack time will be returned."`
Attack *float64 `arg:"" help:"The compressor attack time to set (in ms). If not provided, the current compressor attack time will be returned." optional:""`
}
// Run executes the BusCompAttackCmd command, either retrieving the current compressor attack time of the bus or setting it based on the provided argument.
@@ -504,7 +504,7 @@ func (cmd *BusCompAttackCmd) Run(ctx *context, bus *BusCmdGroup) error {
// BusCompHoldCmd defines the command for getting or setting the compressor hold time of a bus.
type BusCompHoldCmd struct {
Hold *float64 `arg:"" help:"The compressor hold time to set (in ms). If not provided, the current compressor hold time will be returned."`
Hold *float64 `arg:"" help:"The compressor hold time to set (in ms). If not provided, the current compressor hold time will be returned." optional:""`
}
// Run executes the BusCompHoldCmd command, either retrieving the current compressor hold time of the bus or setting it based on the provided argument.
@@ -527,7 +527,7 @@ func (cmd *BusCompHoldCmd) Run(ctx *context, bus *BusCmdGroup) error {
// BusCompReleaseCmd defines the command for getting or setting the compressor release time of a bus.
type BusCompReleaseCmd struct {
Release *float64 `arg:"" help:"The compressor release time to set (in ms). If not provided, the current compressor release time will be returned."`
Release *float64 `arg:"" help:"The compressor release time to set (in ms). If not provided, the current compressor release time will be returned." optional:""`
}
// Run executes the BusCompReleaseCmd command, either retrieving the current compressor release time of the bus or setting it based on the provided argument.

View File

@@ -19,7 +19,7 @@ type HeadampCmdGroup struct {
// HeadampGainCmd defines the command for getting or setting the gain of a headamp, allowing users to specify the gain in dB and an optional duration for a gradual fade when setting the gain.
type HeadampGainCmd struct {
Duration time.Duration `help:"The duration of the fade in/out when setting the gain." default:"5s"`
Gain *float64 `help:"The gain of the headamp in dB." arg:""`
Gain *float64 `help:"The gain of the headamp in dB." arg:"" optional:""`
}
// Run executes the HeadampGainCmd command, either retrieving the current gain of the headamp or setting it based on the provided argument, with an optional fade duration for smooth transitions.

View File

@@ -1,6 +1,7 @@
package xair
var xairAddressMap = map[string]string{
"main": "/lr",
"strip": "/ch/%02d",
"bus": "/bus/%01d",
"headamp": "/headamp/%02d",
@@ -8,9 +9,12 @@ var xairAddressMap = map[string]string{
}
var x32AddressMap = map[string]string{
"strip": "/ch/%02d",
"bus": "/bus/%02d",
"headamp": "/headamp/%02d",
"main": "/main/st",
"mainmono": "/main/mono",
"strip": "/ch/%02d",
"bus": "/bus/%02d",
"headamp": "/headamp/%02d",
"snapshot": "/-snap",
}
func addressMapForMixerKind(kind MixerKind) map[string]string {

View File

@@ -26,8 +26,11 @@ func (b *Bus) Mute(bus int) (bool, error) {
return false, err
}
resp := <-b.client.respChan
val, ok := resp.Arguments[0].(int32)
msg, err := b.client.ReceiveMessage()
if err != nil {
return false, err
}
val, ok := msg.Arguments[0].(int32)
if !ok {
return false, fmt.Errorf("unexpected argument type for bus mute value")
}
@@ -52,8 +55,11 @@ func (b *Bus) Fader(bus int) (float64, error) {
return 0, err
}
resp := <-b.client.respChan
val, ok := resp.Arguments[0].(float32)
msg, err := b.client.ReceiveMessage()
if err != nil {
return 0, err
}
val, ok := msg.Arguments[0].(float32)
if !ok {
return 0, fmt.Errorf("unexpected argument type for bus fader value")
}
@@ -75,8 +81,11 @@ func (b *Bus) Name(bus int) (string, error) {
return "", fmt.Errorf("failed to send bus name request: %v", err)
}
resp := <-b.client.respChan
val, ok := resp.Arguments[0].(string)
msg, err := b.client.ReceiveMessage()
if err != nil {
return "", err
}
val, ok := msg.Arguments[0].(string)
if !ok {
return "", fmt.Errorf("unexpected argument type for bus name value")
}

View File

@@ -10,10 +10,6 @@ import (
"github.com/hypebeast/go-osc/osc"
)
type parser interface {
Parse(data []byte) (*osc.Message, error)
}
type Client struct {
engine
Main *Main
@@ -45,6 +41,7 @@ func NewClient(mixerIP string, mixerPort int, opts ...Option) (*Client, error) {
e := &engine{
Kind: KindXAir,
timeout: 100 * time.Millisecond,
conn: conn,
mixerAddr: mixerAddr,
parser: newParser(),
@@ -60,10 +57,11 @@ func NewClient(mixerIP string, mixerPort int, opts ...Option) (*Client, error) {
c := &Client{
engine: *e,
}
c.Main = newMain(c)
c.Main = newMainStereo(c)
c.Strip = NewStrip(c)
c.Bus = NewBus(c)
c.HeadAmp = NewHeadAmp(c)
c.Snapshot = NewSnapshot(c)
return c, nil
}
@@ -88,32 +86,35 @@ func (c *Client) SendMessage(address string, args ...any) error {
}
// ReceiveMessage receives an OSC message from the mixer
func (c *Client) ReceiveMessage(timeout time.Duration) (*osc.Message, error) {
t := time.Tick(timeout)
func (c *Client) ReceiveMessage() (*osc.Message, error) {
t := time.Tick(c.engine.timeout)
select {
case <-t:
return nil, nil
case val := <-c.respChan:
if val == nil {
return nil, fmt.Errorf("timeout waiting for response")
case msg := <-c.respChan:
if msg == nil {
return nil, fmt.Errorf("no message received")
}
return val, nil
return msg, nil
}
}
// RequestInfo requests mixer information
func (c *Client) RequestInfo() (InfoResponse, error) {
var info InfoResponse
err := c.SendMessage("/xinfo")
if err != nil {
return InfoResponse{}, err
return info, err
}
val := <-c.respChan
var info InfoResponse
if len(val.Arguments) >= 3 {
info.Host = val.Arguments[0].(string)
info.Name = val.Arguments[1].(string)
info.Model = val.Arguments[2].(string)
msg, err := c.ReceiveMessage()
if err != nil {
return info, err
}
if len(msg.Arguments) >= 3 {
info.Host = msg.Arguments[0].(string)
info.Name = msg.Arguments[1].(string)
info.Model = msg.Arguments[2].(string)
}
return info, nil
}

View File

@@ -31,8 +31,11 @@ func (c *Comp) On(index int) (bool, error) {
return false, err
}
resp := <-c.client.respChan
val, ok := resp.Arguments[0].(int32)
msg, err := c.client.ReceiveMessage()
if err != nil {
return false, err
}
val, ok := msg.Arguments[0].(int32)
if !ok {
return false, fmt.Errorf("unexpected argument type for Compressor on value")
}
@@ -59,8 +62,11 @@ func (c *Comp) Mode(index int) (string, error) {
possibleModes := []string{"comp", "exp"}
resp := <-c.client.respChan
val, ok := resp.Arguments[0].(int32)
msg, err := c.client.ReceiveMessage()
if err != nil {
return "", err
}
val, ok := msg.Arguments[0].(int32)
if !ok {
return "", fmt.Errorf("unexpected argument type for Compressor mode value")
}
@@ -82,8 +88,11 @@ func (c *Comp) Threshold(index int) (float64, error) {
return 0, err
}
resp := <-c.client.respChan
val, ok := resp.Arguments[0].(float32)
msg, err := c.client.ReceiveMessage()
if err != nil {
return 0, err
}
val, ok := msg.Arguments[0].(float32)
if !ok {
return 0, fmt.Errorf("unexpected argument type for Compressor threshold value")
}
@@ -106,8 +115,11 @@ func (c *Comp) Ratio(index int) (float32, error) {
possibleValues := []float32{1.1, 1.3, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0, 7.0, 10, 20, 100}
resp := <-c.client.respChan
val, ok := resp.Arguments[0].(int32)
msg, err := c.client.ReceiveMessage()
if err != nil {
return 0, err
}
val, ok := msg.Arguments[0].(int32)
if !ok {
return 0, fmt.Errorf("unexpected argument type for Compressor ratio value")
}
@@ -131,8 +143,11 @@ func (c *Comp) Attack(index int) (float64, error) {
return 0, err
}
resp := <-c.client.respChan
val, ok := resp.Arguments[0].(float32)
msg, err := c.client.ReceiveMessage()
if err != nil {
return 0, err
}
val, ok := msg.Arguments[0].(float32)
if !ok {
return 0, fmt.Errorf("unexpected argument type for Compressor attack value")
}
@@ -153,8 +168,11 @@ func (c *Comp) Hold(index int) (float64, error) {
return 0, err
}
resp := <-c.client.respChan
val, ok := resp.Arguments[0].(float32)
msg, err := c.client.ReceiveMessage()
if err != nil {
return 0, err
}
val, ok := msg.Arguments[0].(float32)
if !ok {
return 0, fmt.Errorf("unexpected argument type for Compressor hold value")
}
@@ -175,8 +193,11 @@ func (c *Comp) Release(index int) (float64, error) {
return 0, err
}
resp := <-c.client.respChan
val, ok := resp.Arguments[0].(float32)
msg, err := c.client.ReceiveMessage()
if err != nil {
return 0, err
}
val, ok := msg.Arguments[0].(float32)
if !ok {
return 0, fmt.Errorf("unexpected argument type for Compressor release value")
}
@@ -197,8 +218,11 @@ func (c *Comp) Makeup(index int) (float64, error) {
return 0, err
}
resp := <-c.client.respChan
val, ok := resp.Arguments[0].(float32)
msg, err := c.client.ReceiveMessage()
if err != nil {
return 0, err
}
val, ok := msg.Arguments[0].(float32)
if !ok {
return 0, fmt.Errorf("unexpected argument type for Compressor makeup gain value")
}
@@ -219,8 +243,11 @@ func (c *Comp) Mix(index int) (float64, error) {
return 0, err
}
resp := <-c.client.respChan
val, ok := resp.Arguments[0].(float32)
msg, err := c.client.ReceiveMessage()
if err != nil {
return 0, err
}
val, ok := msg.Arguments[0].(float32)
if !ok {
return 0, fmt.Errorf("unexpected argument type for Compressor mix value")
}

View File

@@ -9,8 +9,13 @@ import (
"github.com/hypebeast/go-osc/osc"
)
type parser interface {
Parse(data []byte) (*osc.Message, error)
}
type engine struct {
Kind MixerKind
timeout time.Duration
conn *net.UDPConn
mixerAddr *net.UDPAddr
@@ -30,7 +35,7 @@ func (e *engine) receiveLoop() {
case <-e.done:
return
default:
// Set read timeout to avoid blocking forever
// Set a short read deadline to prevent blocking indefinitely
e.conn.SetReadDeadline(time.Now().Add(100 * time.Millisecond))
n, _, err := e.conn.ReadFromUDP(buffer)
if err != nil {

View File

@@ -31,8 +31,11 @@ func (e *Eq) On(index int) (bool, error) {
return false, err
}
resp := <-e.client.respChan
val, ok := resp.Arguments[0].(int32)
msg, err := e.client.ReceiveMessage()
if err != nil {
return false, err
}
val, ok := msg.Arguments[0].(int32)
if !ok {
return false, fmt.Errorf("unexpected argument type for EQ on value")
}
@@ -58,8 +61,11 @@ func (e *Eq) Mode(index int) (string, error) {
possibleModes := []string{"peq", "geq", "teq"}
resp := <-e.client.respChan
val, ok := resp.Arguments[0].(int32)
msg, err := e.client.ReceiveMessage()
if err != nil {
return "", err
}
val, ok := msg.Arguments[0].(int32)
if !ok {
return "", fmt.Errorf("unexpected argument type for EQ mode value")
}
@@ -80,8 +86,11 @@ func (e *Eq) Gain(index int, band int) (float64, error) {
return 0, err
}
resp := <-e.client.respChan
val, ok := resp.Arguments[0].(float32)
msg, err := e.client.ReceiveMessage()
if err != nil {
return 0, err
}
val, ok := msg.Arguments[0].(float32)
if !ok {
return 0, fmt.Errorf("unexpected argument type for EQ gain value")
}
@@ -102,8 +111,11 @@ func (e *Eq) Frequency(index int, band int) (float64, error) {
return 0, err
}
resp := <-e.client.respChan
val, ok := resp.Arguments[0].(float32)
msg, err := e.client.ReceiveMessage()
if err != nil {
return 0, err
}
val, ok := msg.Arguments[0].(float32)
if !ok {
return 0, fmt.Errorf("unexpected argument type for EQ frequency value")
}
@@ -124,8 +136,11 @@ func (e *Eq) Q(index int, band int) (float64, error) {
return 0, err
}
resp := <-e.client.respChan
val, ok := resp.Arguments[0].(float32)
msg, err := e.client.ReceiveMessage()
if err != nil {
return 0, err
}
val, ok := msg.Arguments[0].(float32)
if !ok {
return 0, fmt.Errorf("unexpected argument type for EQ Q value")
}
@@ -148,8 +163,11 @@ func (e *Eq) Type(index int, band int) (string, error) {
possibleTypes := []string{"lcut", "lshv", "peq", "veq", "hshv", "hcut"}
resp := <-e.client.respChan
val, ok := resp.Arguments[0].(int32)
msg, err := e.client.ReceiveMessage()
if err != nil {
return "", err
}
val, ok := msg.Arguments[0].(int32)
if !ok {
return "", fmt.Errorf("unexpected argument type for EQ type value")
}

View File

@@ -19,8 +19,11 @@ func (g *Gate) On(index int) (bool, error) {
return false, err
}
resp := <-g.client.respChan
val, ok := resp.Arguments[0].(int32)
msg, err := g.client.ReceiveMessage()
if err != nil {
return false, err
}
val, ok := msg.Arguments[0].(int32)
if !ok {
return false, fmt.Errorf("unexpected argument type for Gate on value")
}
@@ -47,8 +50,11 @@ func (g *Gate) Mode(index int) (string, error) {
possibleModes := []string{"exp2", "exp3", "exp4", "gate", "duck"}
resp := <-g.client.respChan
val, ok := resp.Arguments[0].(int32)
msg, err := g.client.ReceiveMessage()
if err != nil {
return "", err
}
val, ok := msg.Arguments[0].(int32)
if !ok {
return "", fmt.Errorf("unexpected argument type for Gate mode value")
}
@@ -71,8 +77,11 @@ func (g *Gate) Threshold(index int) (float64, error) {
return 0, err
}
resp := <-g.client.respChan
val, ok := resp.Arguments[0].(float32)
msg, err := g.client.ReceiveMessage()
if err != nil {
return 0, err
}
val, ok := msg.Arguments[0].(float32)
if !ok {
return 0, fmt.Errorf("unexpected argument type for Gate threshold value")
}
@@ -93,8 +102,11 @@ func (g *Gate) Range(index int) (float64, error) {
return 0, err
}
resp := <-g.client.respChan
val, ok := resp.Arguments[0].(float32)
msg, err := g.client.ReceiveMessage()
if err != nil {
return 0, err
}
val, ok := msg.Arguments[0].(float32)
if !ok {
return 0, fmt.Errorf("unexpected argument type for Gate range value")
}
@@ -115,8 +127,11 @@ func (g *Gate) Attack(index int) (float64, error) {
return 0, err
}
resp := <-g.client.respChan
val, ok := resp.Arguments[0].(float32)
msg, err := g.client.ReceiveMessage()
if err != nil {
return 0, err
}
val, ok := msg.Arguments[0].(float32)
if !ok {
return 0, fmt.Errorf("unexpected argument type for Gate attack value")
}
@@ -137,8 +152,11 @@ func (g *Gate) Hold(index int) (float64, error) {
return 0, err
}
resp := <-g.client.respChan
val, ok := resp.Arguments[0].(float32)
msg, err := g.client.ReceiveMessage()
if err != nil {
return 0, err
}
val, ok := msg.Arguments[0].(float32)
if !ok {
return 0, fmt.Errorf("unexpected argument type for Gate hold value")
}
@@ -159,8 +177,11 @@ func (g *Gate) Release(index int) (float64, error) {
return 0, err
}
resp := <-g.client.respChan
val, ok := resp.Arguments[0].(float32)
msg, err := g.client.ReceiveMessage()
if err != nil {
return 0, err
}
val, ok := msg.Arguments[0].(float32)
if !ok {
return 0, fmt.Errorf("unexpected argument type for Gate release value")
}

View File

@@ -22,8 +22,11 @@ func (h *HeadAmp) Gain(index int) (float64, error) {
return 0, err
}
resp := <-h.client.respChan
val, ok := resp.Arguments[0].(float32)
msg, err := h.client.ReceiveMessage()
if err != nil {
return 0, err
}
val, ok := msg.Arguments[0].(float32)
if !ok {
return 0, fmt.Errorf("unexpected argument type for headamp gain value")
}
@@ -45,8 +48,11 @@ func (h *HeadAmp) PhantomPower(index int) (bool, error) {
return false, err
}
resp := <-h.client.respChan
val, ok := resp.Arguments[0].(int32)
msg, err := h.client.ReceiveMessage()
if err != nil {
return false, err
}
val, ok := msg.Arguments[0].(int32)
if !ok {
return false, fmt.Errorf("unexpected argument type for phantom power value")
}

View File

@@ -3,24 +3,39 @@ package xair
import "fmt"
type Main struct {
client *Client
baseAddress string
client *Client
}
func newMain(c *Client) *Main {
func newMainStereo(c *Client) *Main {
return &Main{
client: c,
baseAddress: c.addressMap["main"],
client: c,
}
}
/* Still considering the best way to implement main mono support.
func newMainMono(c *Client) *Main {
return &Main{
baseAddress: c.addressMap["mainmono"],
client: c,
}
}
*/
// Fader requests the current main L/R fader level
func (m *Main) Fader() (float64, error) {
err := m.client.SendMessage("/lr/mix/fader")
address := m.baseAddress + "/mix/fader"
err := m.client.SendMessage(address)
if err != nil {
return 0, err
}
resp := <-m.client.respChan
val, ok := resp.Arguments[0].(float32)
msg, err := m.client.ReceiveMessage()
if err != nil {
return 0, err
}
val, ok := msg.Arguments[0].(float32)
if !ok {
return 0, fmt.Errorf("unexpected argument type for main LR fader value")
}
@@ -29,18 +44,23 @@ func (m *Main) Fader() (float64, error) {
// SetFader sets the main L/R fader level
func (m *Main) SetFader(level float64) error {
return m.client.SendMessage("/lr/mix/fader", float32(mustDbInto(level)))
address := m.baseAddress + "/mix/fader"
return m.client.SendMessage(address, float32(mustDbInto(level)))
}
// Mute requests the current main L/R mute status
func (m *Main) Mute() (bool, error) {
err := m.client.SendMessage("/lr/mix/on")
address := m.baseAddress + "/mix/on"
err := m.client.SendMessage(address)
if err != nil {
return false, err
}
resp := <-m.client.respChan
val, ok := resp.Arguments[0].(int32)
msg, err := m.client.ReceiveMessage()
if err != nil {
return false, err
}
val, ok := msg.Arguments[0].(int32)
if !ok {
return false, fmt.Errorf("unexpected argument type for main LR mute value")
}
@@ -49,9 +69,10 @@ func (m *Main) Mute() (bool, error) {
// SetMute sets the main L/R mute status
func (m *Main) SetMute(muted bool) error {
address := m.baseAddress + "/mix/on"
var value int32
if !muted {
value = 1
}
return m.client.SendMessage("/lr/mix/on", value)
return m.client.SendMessage(address, value)
}

View File

@@ -1,5 +1,7 @@
package xair
import "time"
type Option func(*engine)
func WithKind(kind string) Option {
@@ -8,3 +10,9 @@ func WithKind(kind string) Option {
e.addressMap = addressMapForMixerKind(e.Kind)
}
}
func WithTimeout(timeout time.Duration) Option {
return func(e *engine) {
e.timeout = timeout
}
}

View File

@@ -16,14 +16,17 @@ func NewSnapshot(c *Client) *Snapshot {
// Name gets the name of the snapshot at the given index.
func (s *Snapshot) Name(index int) (string, error) {
address := s.baseAddress + fmt.Sprintf("/name/%d", index)
address := s.baseAddress + fmt.Sprintf("/%02d/name", index)
err := s.client.SendMessage(address)
if err != nil {
return "", err
}
resp := <-s.client.respChan
name, ok := resp.Arguments[0].(string)
msg, err := s.client.ReceiveMessage()
if err != nil {
return "", err
}
name, ok := msg.Arguments[0].(string)
if !ok {
return "", fmt.Errorf("unexpected argument type for snapshot name")
}
@@ -32,24 +35,30 @@ func (s *Snapshot) Name(index int) (string, error) {
// SetName sets the name of the snapshot at the given index.
func (s *Snapshot) SetName(index int, name string) error {
address := s.baseAddress + fmt.Sprintf("/name/%d", index)
address := s.baseAddress + fmt.Sprintf("/%02d/name", index)
return s.client.SendMessage(address, name)
}
// Load loads the snapshot at the given index.
func (s *Snapshot) Load(index int) error {
address := s.baseAddress + fmt.Sprintf("/load/%d", index)
return s.client.SendMessage(address)
// CurrentName sets the name of the current snapshot.
func (s *Snapshot) CurrentName(name string) error {
address := s.baseAddress + "/name"
return s.client.SendMessage(address, name)
}
// Save saves the current state to the snapshot at the given index.
func (s *Snapshot) Save(index int) error {
address := s.baseAddress + fmt.Sprintf("/save/%d", index)
return s.client.SendMessage(address)
// CurrentLoad loads the snapshot at the given index.
func (s *Snapshot) CurrentLoad(index int) error {
address := s.baseAddress + "/load"
return s.client.SendMessage(address, int32(index))
}
// Delete deletes the snapshot at the given index.
func (s *Snapshot) Delete(index int) error {
address := s.baseAddress + fmt.Sprintf("/delete/%d", index)
return s.client.SendMessage(address)
// CurrentSave saves the current state to the snapshot at the given index.
func (s *Snapshot) CurrentSave(index int) error {
address := s.baseAddress + "/save"
return s.client.SendMessage(address, int32(index))
}
// CurrentDelete deletes the snapshot at the given index.
func (s *Snapshot) CurrentDelete(index int) error {
address := s.baseAddress + "/delete"
return s.client.SendMessage(address, int32(index))
}

View File

@@ -28,8 +28,11 @@ func (s *Strip) Mute(index int) (bool, error) {
return false, err
}
resp := <-s.client.respChan
val, ok := resp.Arguments[0].(int32)
msg, err := s.client.ReceiveMessage()
if err != nil {
return false, err
}
val, ok := msg.Arguments[0].(int32)
if !ok {
return false, fmt.Errorf("unexpected argument type for strip mute value")
}
@@ -54,8 +57,11 @@ func (s *Strip) Fader(strip int) (float64, error) {
return 0, err
}
resp := <-s.client.respChan
val, ok := resp.Arguments[0].(float32)
msg, err := s.client.ReceiveMessage()
if err != nil {
return 0, err
}
val, ok := msg.Arguments[0].(float32)
if !ok {
return 0, fmt.Errorf("unexpected argument type for fader value")
}
@@ -77,8 +83,11 @@ func (s *Strip) Name(strip int) (string, error) {
return "", fmt.Errorf("failed to send strip name request: %v", err)
}
resp := <-s.client.respChan
val, ok := resp.Arguments[0].(string)
msg, err := s.client.ReceiveMessage()
if err != nil {
return "", err
}
val, ok := msg.Arguments[0].(string)
if !ok {
return "", fmt.Errorf("unexpected argument type for strip name value")
}
@@ -99,8 +108,11 @@ func (s *Strip) Color(strip int) (int32, error) {
return 0, fmt.Errorf("failed to send strip color request: %v", err)
}
resp := <-s.client.respChan
val, ok := resp.Arguments[0].(int32)
msg, err := s.client.ReceiveMessage()
if err != nil {
return 0, err
}
val, ok := msg.Arguments[0].(int32)
if !ok {
return 0, fmt.Errorf("unexpected argument type for strip color value")
}
@@ -121,8 +133,11 @@ func (s *Strip) SendLevel(strip int, bus int) (float64, error) {
return 0, fmt.Errorf("failed to send strip send level request: %v", err)
}
resp := <-s.client.respChan
val, ok := resp.Arguments[0].(float32)
msg, err := s.client.ReceiveMessage()
if err != nil {
return 0, err
}
val, ok := msg.Arguments[0].(float32)
if !ok {
return 0, fmt.Errorf("unexpected argument type for strip send level value")
}

4
lr.go
View File

@@ -16,7 +16,7 @@ type MainCmdGroup struct {
// MainMuteCmd defines the command for getting or setting the mute state of the Main L/R output, allowing users to specify the desired state as "true"/"on" or "false"/"off".
type MainMuteCmd struct {
Mute *bool `arg:"" help:"The mute state to set. If not provided, the current state will be printed."`
Mute *bool `arg:"" help:"The mute state to set. If not provided, the current state will be printed." optional:""`
}
// Run executes the MainMuteCmd command, either retrieving the current mute state of the Main L/R output or setting it based on the provided argument.
@@ -39,7 +39,7 @@ func (cmd *MainMuteCmd) Run(ctx *context) error {
// MainFaderCmd defines the command for getting or setting the fader level of the Main L/R output, allowing users to specify the desired level in dB.
type MainFaderCmd struct {
Level *float64 `arg:"" help:"The fader level to set. If not provided, the current level will be printed."`
Level *float64 `arg:"" help:"The fader level to set. If not provided, the current level will be printed." optional:""`
}
// Run executes the MainFaderCmd command, either retrieving the current fader level of the Main L/R output or setting it based on the provided argument.

35
main.go
View File

@@ -6,6 +6,7 @@ import (
"os"
"runtime/debug"
"strings"
"time"
"github.com/alecthomas/kong"
"github.com/charmbracelet/log"
@@ -32,9 +33,11 @@ type context struct {
}
type Config struct {
Host string `default:"mixer.local" help:"The host of the X-Air device." env:"XAIR_CLI_HOST"`
Port int `default:"10024" help:"The port of the X-Air device." env:"XAIR_CLI_PORT"`
Kind string `default:"xr18" help:"The kind of the X-Air device." env:"XAIR_CLI_KIND"`
Host string `default:"mixer.local" help:"The host of the X-Air device." env:"XAIR_CLI_HOST" short:"H"`
Port int `default:"10024" help:"The port of the X-Air device." env:"XAIR_CLI_PORT" short:"P"`
Kind string `default:"xair" help:"The kind of the X-Air device." env:"XAIR_CLI_KIND" short:"K" enum:"xair,x32"`
Timeout time.Duration `default:"100ms" help:"Timeout for OSC operations." env:"XAIR_CLI_TIMEOUT" short:"T"`
Loglevel string `default:"warn" help:"Log level for the CLI." env:"XAIR_CLI_LOGLEVEL" short:"L" enum:"debug,info,warn,error,fatal"`
}
// CLI is the main struct for the command-line interface.
@@ -42,15 +45,16 @@ type Config struct {
type CLI struct {
Config `embed:"" prefix:"" help:"The configuration for the CLI."`
Version VersionFlag `help:"Print gobs-cli version information and quit" name:"version" short:"v"`
Version VersionFlag `help:"Print xair-cli version information and quit" name:"version" short:"v"`
Completion kongcompletion.Completion `help:"Generate shell completion scripts." cmd:"" aliases:"c"`
Raw RawCmd `help:"Send raw OSC messages to the mixer." cmd:"" group:"Raw"`
Main MainCmdGroup `help:"Control the Main L/R output" cmd:"" group:"Main"`
Strip StripCmdGroup `help:"Control the strips." cmd:"" group:"Strip"`
Bus BusCmdGroup `help:"Control the buses." cmd:"" group:"Bus"`
Headamp HeadampCmdGroup `help:"Control input gain and phantom power." cmd:"" group:"Headamp"`
Raw RawCmd `help:"Send raw OSC messages to the mixer." cmd:"" group:"Raw"`
Main MainCmdGroup `help:"Control the Main L/R output" cmd:"" group:"Main"`
Strip StripCmdGroup `help:"Control the strips." cmd:"" group:"Strip"`
Bus BusCmdGroup `help:"Control the buses." cmd:"" group:"Bus"`
Headamp HeadampCmdGroup `help:"Control input gain and phantom power." cmd:"" group:"Headamp"`
Snapshot SnapshotCmdGroup `help:"Save and load mixer states." cmd:"" group:"Snapshot"`
}
func main() {
@@ -84,6 +88,12 @@ func main() {
// run is the main entry point for the CLI.
// It connects to the X-Air device, retrieves mixer info, and then runs the command.
func run(ctx *kong.Context, config Config) error {
loglevel, err := log.ParseLevel(config.Loglevel)
if err != nil {
return fmt.Errorf("invalid log level: %w", err)
}
log.SetLevel(loglevel)
client, err := connect(config)
if err != nil {
return fmt.Errorf("failed to connect to X-Air device: %w", err)
@@ -107,7 +117,12 @@ func run(ctx *kong.Context, config Config) error {
// connect creates a new X-Air client based on the provided configuration.
func connect(config Config) (*xair.Client, error) {
client, err := xair.NewClient(config.Host, config.Port, xair.WithKind(config.Kind))
client, err := xair.NewClient(
config.Host,
config.Port,
xair.WithKind(config.Kind),
xair.WithTimeout(config.Timeout),
)
if err != nil {
return nil, err
}

15
raw.go
View File

@@ -2,14 +2,14 @@ package main
import (
"fmt"
"time"
"github.com/charmbracelet/log"
)
// RawCmd represents the command to send raw OSC messages to the mixer.
type RawCmd struct {
Timeout time.Duration `help:"Timeout for the OSC message send operation." default:"200ms" short:"t"`
Address string `help:"The OSC address to send the message to." arg:""`
Args []string `help:"The arguments to include in the OSC message." arg:"" optional:""`
Address string `help:"The OSC address to send the message to." arg:""`
Args []string `help:"The arguments to include in the OSC message." arg:"" optional:""`
}
// Run executes the RawCmd by sending the specified OSC message to the mixer and optionally waiting for a response.
@@ -22,7 +22,12 @@ func (cmd *RawCmd) Run(ctx *context) error {
return fmt.Errorf("failed to send raw OSC message: %w", err)
}
msg, err := ctx.Client.ReceiveMessage(cmd.Timeout)
if len(params) > 0 {
log.Debugf("Sent OSC message: %s with args: %v\n", cmd.Address, cmd.Args)
return nil
}
msg, err := ctx.Client.ReceiveMessage()
if err != nil {
return fmt.Errorf("failed to receive response for raw OSC message: %w", err)
}

75
snapshot.go Normal file
View File

@@ -0,0 +1,75 @@
package main
import "fmt"
type SnapshotCmdGroup struct {
List ListCmd `help:"List all snapshots." cmd:"list"`
Index struct {
Index int `arg:"" help:"The index of the snapshot."`
Name NameCmd `help:"Get or set the name of a snapshot." cmd:"name"`
Save SaveCmd `help:"Save the current mixer state." cmd:"save"`
Load LoadCmd `help:"Load a mixer state." cmd:"load"`
Delete DeleteCmd `help:"Delete a snapshot." cmd:"delete"`
} `help:"The index of the snapshot." arg:""`
}
type ListCmd struct {
}
func (c *ListCmd) Run(ctx *context) error {
for i := range 64 {
name, err := ctx.Client.Snapshot.Name(i + 1)
if err != nil {
break
}
if name == "" {
continue
}
fmt.Fprintf(ctx.Out, "%d: %s\n", i+1, name)
}
return nil
}
type NameCmd struct {
Name *string `arg:"" help:"The name of the snapshot." optional:""`
}
func (c *NameCmd) Run(ctx *context, snapshot *SnapshotCmdGroup) error {
if c.Name == nil {
name, err := ctx.Client.Snapshot.Name(snapshot.Index.Index)
if err != nil {
return err
}
fmt.Fprintln(ctx.Out, name)
return nil
}
return ctx.Client.Snapshot.SetName(snapshot.Index.Index, *c.Name)
}
type SaveCmd struct {
Name string `arg:"" help:"The name of the snapshot."`
}
func (c *SaveCmd) Run(ctx *context, snapshot *SnapshotCmdGroup) error {
err := ctx.Client.Snapshot.CurrentName(c.Name)
if err != nil {
return err
}
return ctx.Client.Snapshot.CurrentSave(snapshot.Index.Index)
}
type LoadCmd struct {
}
func (c *LoadCmd) Run(ctx *context, snapshot *SnapshotCmdGroup) error {
return ctx.Client.Snapshot.CurrentLoad(snapshot.Index.Index)
}
type DeleteCmd struct {
}
func (c *DeleteCmd) Run(ctx *context, snapshot *SnapshotCmdGroup) error {
return ctx.Client.Snapshot.CurrentDelete(snapshot.Index.Index)
}