mirror of
https://github.com/onyx-and-iris/xair-cli.git
synced 2026-04-09 02:13:35 +00:00
initial commit
add internal/xair module
This commit is contained in:
276
internal/xair/client.go
Normal file
276
internal/xair/client.go
Normal file
@@ -0,0 +1,276 @@
|
||||
package xair
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
|
||||
"github.com/hypebeast/go-osc/osc"
|
||||
)
|
||||
|
||||
type parser interface {
|
||||
Parse(data []byte) (*osc.Message, error)
|
||||
}
|
||||
|
||||
type XAirClient struct {
|
||||
conn *net.UDPConn
|
||||
mixerAddr *net.UDPAddr
|
||||
|
||||
parser parser
|
||||
|
||||
done chan bool
|
||||
respChan chan *osc.Message
|
||||
}
|
||||
|
||||
// NewClient creates a new XAirClient instance
|
||||
func NewClient(mixerIP string, mixerPort int) (*XAirClient, error) {
|
||||
localAddr, err := net.ResolveUDPAddr("udp", fmt.Sprintf(":%d", 0))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to resolve local address: %v", err)
|
||||
}
|
||||
|
||||
conn, err := net.ListenUDP("udp", localAddr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create UDP connection: %v", err)
|
||||
}
|
||||
|
||||
mixerAddr, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", mixerIP, mixerPort))
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("failed to resolve mixer address: %v", err)
|
||||
}
|
||||
|
||||
log.Debugf("Local UDP connection: %s ", conn.LocalAddr().String())
|
||||
|
||||
return &XAirClient{
|
||||
conn: conn,
|
||||
mixerAddr: mixerAddr,
|
||||
parser: newParser(),
|
||||
done: make(chan bool),
|
||||
respChan: make(chan *osc.Message),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Start begins listening for messages in a goroutine
|
||||
func (x *XAirClient) StartListening() {
|
||||
go x.receiveLoop()
|
||||
log.Debugf("Started listening on %s...", x.conn.LocalAddr().String())
|
||||
}
|
||||
|
||||
// receiveLoop handles incoming OSC messages
|
||||
func (x *XAirClient) receiveLoop() {
|
||||
buffer := make([]byte, 4096)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-x.done:
|
||||
return
|
||||
default:
|
||||
// Set read timeout to avoid blocking forever
|
||||
x.conn.SetReadDeadline(time.Now().Add(100 * time.Millisecond))
|
||||
|
||||
n, _, err := x.conn.ReadFromUDP(buffer)
|
||||
if err != nil {
|
||||
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
|
||||
// Timeout is expected, continue loop
|
||||
continue
|
||||
}
|
||||
// Check if we're shutting down to avoid logging expected errors
|
||||
select {
|
||||
case <-x.done:
|
||||
return
|
||||
default:
|
||||
log.Errorf("Read error: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
msg, err := x.parseOSCMessage(buffer[:n])
|
||||
if err != nil {
|
||||
log.Errorf("Failed to parse OSC message: %v", err)
|
||||
continue
|
||||
}
|
||||
x.respChan <- msg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parseOSCMessage parses raw bytes into an OSC message with improved error handling
|
||||
func (x *XAirClient) parseOSCMessage(data []byte) (*osc.Message, error) {
|
||||
msg, err := x.parser.Parse(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// Stop stops the client and closes the connection
|
||||
func (x *XAirClient) Stop() {
|
||||
close(x.done)
|
||||
if x.conn != nil {
|
||||
x.conn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// SendMessage sends an OSC message to the mixer using the unified connection
|
||||
func (x *XAirClient) SendMessage(address string, args ...any) error {
|
||||
return x.SendToAddress(x.mixerAddr, address, args...)
|
||||
}
|
||||
|
||||
// SendToAddress sends an OSC message to a specific address (enables replying to different ports)
|
||||
func (x *XAirClient) SendToAddress(addr *net.UDPAddr, oscAddress string, args ...any) error {
|
||||
msg := osc.NewMessage(oscAddress)
|
||||
for _, arg := range args {
|
||||
msg.Append(arg)
|
||||
}
|
||||
|
||||
log.Debugf("Sending to %v: %s", addr, msg.String())
|
||||
if len(args) > 0 {
|
||||
log.Debug(" - Arguments: ")
|
||||
for i, arg := range args {
|
||||
if i > 0 {
|
||||
log.Debug(", ")
|
||||
}
|
||||
log.Debugf("%v", arg)
|
||||
}
|
||||
}
|
||||
log.Debug("")
|
||||
|
||||
data, err := msg.MarshalBinary()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal message: %v", err)
|
||||
}
|
||||
|
||||
_, err = x.conn.WriteToUDP(data, addr)
|
||||
return err
|
||||
}
|
||||
|
||||
// RequestInfo requests mixer information
|
||||
func (x *XAirClient) RequestInfo() (error, InfoResponse) {
|
||||
err := x.SendMessage("/xinfo")
|
||||
if err != nil {
|
||||
return err, InfoResponse{}
|
||||
}
|
||||
|
||||
val := <-x.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)
|
||||
}
|
||||
return nil, info
|
||||
}
|
||||
|
||||
// KeepAlive sends keep-alive message (required for multi-client usage)
|
||||
func (x *XAirClient) KeepAlive() error {
|
||||
return x.SendMessage("/xremote")
|
||||
}
|
||||
|
||||
// RequestStatus requests mixer status
|
||||
func (x *XAirClient) RequestStatus() error {
|
||||
return x.SendMessage("/status")
|
||||
}
|
||||
|
||||
// SetChannelGain sets gain for a specific channel (1-based indexing)
|
||||
func (x *XAirClient) SetChannelGain(channel int, gain float32) error {
|
||||
address := fmt.Sprintf("/ch/%02d/mix/fader", channel)
|
||||
return x.SendMessage(address, gain)
|
||||
}
|
||||
|
||||
// MuteChannel mutes/unmutes a specific channel (1-based indexing)
|
||||
func (x *XAirClient) MuteChannel(channel int, muted bool) error {
|
||||
address := fmt.Sprintf("/ch/%02d/mix/on", channel)
|
||||
var value int32 = 0
|
||||
if !muted {
|
||||
value = 1
|
||||
}
|
||||
return x.SendMessage(address, value)
|
||||
}
|
||||
|
||||
// GetMainLRFader requests the current main L/R fader level
|
||||
func (x *XAirClient) GetMainLRFader() (float64, error) {
|
||||
err := x.SendMessage("/lr/mix/fader")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
resp := <-x.respChan
|
||||
val, ok := resp.Arguments[0].(float32)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("unexpected argument type for main LR fader value")
|
||||
}
|
||||
return mustDbFrom(float64(val)), nil
|
||||
}
|
||||
|
||||
// SetMainLRFader sets the main L/R fader level
|
||||
func (x *XAirClient) SetMainLRFader(level float64) error {
|
||||
return x.SendMessage("/lr/mix/fader", float32(mustDbInto(level)))
|
||||
}
|
||||
|
||||
// GetChannelFader requests the current fader level for a channel
|
||||
func (x *XAirClient) GetChannelFader(channel int) (float64, error) {
|
||||
address := fmt.Sprintf("/ch/%02d/mix/fader", channel)
|
||||
err := x.SendMessage(address)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
resp := <-x.respChan
|
||||
val, ok := resp.Arguments[0].(float32)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("unexpected argument type for fader value")
|
||||
}
|
||||
|
||||
return mustDbFrom(float64(val)), nil
|
||||
}
|
||||
|
||||
// GetChannelMute requests the current mute state for a channel
|
||||
func (x *XAirClient) GetChannelMute(channel int) error {
|
||||
address := fmt.Sprintf("/ch/%02d/mix/on", channel)
|
||||
return x.SendMessage(address)
|
||||
}
|
||||
|
||||
// SetChannelName sets the name for a specific channel
|
||||
func (x *XAirClient) SetChannelName(channel int, name string) error {
|
||||
address := fmt.Sprintf("/ch/%02d/config/name", channel)
|
||||
return x.SendMessage(address, name)
|
||||
}
|
||||
|
||||
// GetChannelName requests the name for a specific channel
|
||||
func (x *XAirClient) GetChannelName(channel int) error {
|
||||
address := fmt.Sprintf("/ch/%02d/config/name", channel)
|
||||
return x.SendMessage(address)
|
||||
}
|
||||
|
||||
// GetChannelColor requests the color for a specific channel
|
||||
func (x *XAirClient) GetChannelColor(channel int) error {
|
||||
address := fmt.Sprintf("/ch/%02d/config/color", channel)
|
||||
return x.SendMessage(address)
|
||||
}
|
||||
|
||||
// SetChannelColor sets the color for a specific channel (0-15)
|
||||
func (x *XAirClient) SetChannelColor(channel int, color int32) error {
|
||||
address := fmt.Sprintf("/ch/%02d/config/color", channel)
|
||||
return x.SendMessage(address, color)
|
||||
}
|
||||
|
||||
// GetAllChannelInfo requests information for all channels
|
||||
func (x *XAirClient) GetAllChannelInfo(maxChannels int) error {
|
||||
fmt.Printf("\n=== REQUESTING ALL CHANNEL INFO (1-%d) ===\n", maxChannels)
|
||||
for ch := 1; ch <= maxChannels; ch++ {
|
||||
fmt.Printf("Requesting info for channel %d...\n", ch)
|
||||
x.GetChannelName(ch)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
x.GetChannelFader(ch)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
x.GetChannelMute(ch)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
x.GetChannelColor(ch)
|
||||
time.Sleep(200 * time.Millisecond) // Longer pause between channels
|
||||
}
|
||||
return nil
|
||||
}
|
||||
7
internal/xair/models.go
Normal file
7
internal/xair/models.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package xair
|
||||
|
||||
type InfoResponse struct {
|
||||
Host string
|
||||
Name string
|
||||
Model string
|
||||
}
|
||||
213
internal/xair/parser.go
Normal file
213
internal/xair/parser.go
Normal file
@@ -0,0 +1,213 @@
|
||||
package xair
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/hypebeast/go-osc/osc"
|
||||
)
|
||||
|
||||
type xairParser struct {
|
||||
}
|
||||
|
||||
func newParser() *xairParser {
|
||||
return &xairParser{}
|
||||
}
|
||||
|
||||
// parseOSCMessage parses raw bytes into an OSC message with improved error handling
|
||||
func (p *xairParser) Parse(data []byte) (*osc.Message, error) {
|
||||
log.Debug("=== PARSING OSC MESSAGE BEGIN ===")
|
||||
defer log.Debug("=== PARSING OSC MESSAGE END ===")
|
||||
|
||||
if err := p.validateOSCData(data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
address, addressEnd, err := p.extractOSCAddress(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
msg := osc.NewMessage(address)
|
||||
|
||||
typeTags, typeTagsEnd, err := p.extractOSCTypeTags(data, addressEnd)
|
||||
if err != nil || typeTags == "" {
|
||||
log.Debug("No valid type tags, returning address-only message")
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
if err := p.parseOSCArguments(data, typeTagsEnd, typeTags, msg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Debugf("Successfully parsed message with %d arguments", len(msg.Arguments))
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// validateOSCData performs basic validation on OSC message data
|
||||
func (p *xairParser) validateOSCData(data []byte) error {
|
||||
if len(data) < 4 {
|
||||
return fmt.Errorf("data too short for OSC message")
|
||||
}
|
||||
if data[0] != '/' {
|
||||
return fmt.Errorf("invalid OSC message: does not start with '/'")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractOSCAddress extracts the OSC address from the message data
|
||||
func (p *xairParser) extractOSCAddress(data []byte) (address string, nextPos int, err error) {
|
||||
nullPos := bytes.IndexByte(data, 0)
|
||||
if nullPos <= 0 {
|
||||
return "", 0, fmt.Errorf("no null terminator found for address")
|
||||
}
|
||||
|
||||
address = string(data[:nullPos])
|
||||
log.Debugf("Parsed OSC address: %s", address)
|
||||
|
||||
// Calculate next 4-byte aligned position
|
||||
nextPos = ((nullPos + 4) / 4) * 4
|
||||
return address, nextPos, nil
|
||||
}
|
||||
|
||||
// extractOSCTypeTags extracts and validates OSC type tags
|
||||
func (p *xairParser) extractOSCTypeTags(data []byte, start int) (typeTags string, nextPos int, err error) {
|
||||
if start >= len(data) {
|
||||
return "", start, nil // No type tags available
|
||||
}
|
||||
|
||||
typeTagsEnd := bytes.IndexByte(data[start:], 0)
|
||||
if typeTagsEnd <= 0 {
|
||||
return "", start, nil // No type tags found
|
||||
}
|
||||
|
||||
typeTags = string(data[start : start+typeTagsEnd])
|
||||
log.Debugf("Parsed type tags: %s", typeTags)
|
||||
|
||||
if len(typeTags) == 0 || typeTags[0] != ',' {
|
||||
log.Debug("Invalid type tags format")
|
||||
return "", start, nil
|
||||
}
|
||||
|
||||
// Calculate arguments start position (4-byte aligned)
|
||||
nextPos = ((start + typeTagsEnd + 4) / 4) * 4
|
||||
return typeTags, nextPos, nil
|
||||
}
|
||||
|
||||
// parseOSCArguments parses OSC arguments based on type tags
|
||||
func (p *xairParser) parseOSCArguments(data []byte, argsStart int, typeTags string, msg *osc.Message) error {
|
||||
argData := data[argsStart:]
|
||||
argNum := 0
|
||||
|
||||
for i := 1; i < len(typeTags) && len(argData) > 0; i++ {
|
||||
var consumed int
|
||||
var err error
|
||||
|
||||
switch typeTags[i] {
|
||||
case 's':
|
||||
consumed, err = p.parseStringArgument(argData, msg, argNum)
|
||||
case 'i':
|
||||
consumed, err = p.parseInt32Argument(argData, msg, argNum)
|
||||
case 'f':
|
||||
consumed, err = p.parseFloat32Argument(argData, msg, argNum)
|
||||
case 'b':
|
||||
consumed, err = p.parseBlobArgument(argData, msg, argNum)
|
||||
default:
|
||||
log.Debugf("Unknown type tag: %c (skipping)", typeTags[i])
|
||||
consumed = p.skipUnknownArgument(argData)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Debugf("Error parsing argument %d: %v", argNum+1, err)
|
||||
break
|
||||
}
|
||||
|
||||
if consumed == 0 {
|
||||
break // No more data to consume
|
||||
}
|
||||
|
||||
argData = argData[consumed:]
|
||||
if typeTags[i] != '?' { // Don't count skipped arguments
|
||||
argNum++
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseStringArgument parses a string argument from OSC data
|
||||
func (p *xairParser) parseStringArgument(data []byte, msg *osc.Message, argNum int) (int, error) {
|
||||
nullPos := bytes.IndexByte(data, 0)
|
||||
if nullPos < 0 {
|
||||
return 0, fmt.Errorf("no null terminator found for string")
|
||||
}
|
||||
|
||||
argStr := string(data[:nullPos])
|
||||
log.Debugf("Parsed string argument %d: %s", argNum+1, argStr)
|
||||
msg.Append(argStr)
|
||||
|
||||
// Return next 4-byte aligned position
|
||||
return ((nullPos + 4) / 4) * 4, nil
|
||||
}
|
||||
|
||||
// parseInt32Argument parses an int32 argument from OSC data
|
||||
func (p *xairParser) parseInt32Argument(data []byte, msg *osc.Message, argNum int) (int, error) {
|
||||
if len(data) < 4 {
|
||||
return 0, fmt.Errorf("insufficient data for int32")
|
||||
}
|
||||
|
||||
val := int32(binary.BigEndian.Uint32(data[:4]))
|
||||
log.Debugf("Parsed int32 argument %d: %d", argNum+1, val)
|
||||
msg.Append(val)
|
||||
|
||||
return 4, nil
|
||||
}
|
||||
|
||||
// parseFloat32Argument parses a float32 argument from OSC data
|
||||
func (p *xairParser) parseFloat32Argument(data []byte, msg *osc.Message, argNum int) (int, error) {
|
||||
if len(data) < 4 {
|
||||
return 0, fmt.Errorf("insufficient data for float32")
|
||||
}
|
||||
|
||||
val := math.Float32frombits(binary.BigEndian.Uint32(data[:4]))
|
||||
log.Debugf("Parsed float32 argument %d: %f", argNum+1, val)
|
||||
msg.Append(val)
|
||||
|
||||
return 4, nil
|
||||
}
|
||||
|
||||
// parseBlobArgument parses a blob argument from OSC data
|
||||
func (p *xairParser) parseBlobArgument(data []byte, msg *osc.Message, argNum int) (int, error) {
|
||||
if len(data) < 4 {
|
||||
return 0, fmt.Errorf("insufficient data for blob size")
|
||||
}
|
||||
|
||||
size := int32(binary.BigEndian.Uint32(data[:4]))
|
||||
if size < 0 || size >= 10000 {
|
||||
return 0, fmt.Errorf("invalid blob size: %d", size)
|
||||
}
|
||||
|
||||
if len(data) < int(4+size) {
|
||||
return 0, fmt.Errorf("insufficient data for blob content")
|
||||
}
|
||||
|
||||
blob := make([]byte, size)
|
||||
copy(blob, data[4:4+size])
|
||||
log.Debugf("Parsed blob argument %d (%d bytes)", argNum+1, size)
|
||||
msg.Append(blob)
|
||||
|
||||
// Return next 4-byte aligned position
|
||||
return ((4 + int(size) + 3) / 4) * 4, nil
|
||||
}
|
||||
|
||||
// skipUnknownArgument skips an unknown argument type
|
||||
func (p *xairParser) skipUnknownArgument(data []byte) int {
|
||||
// Skip unknown types by moving 4 bytes if available
|
||||
if len(data) >= 4 {
|
||||
return 4
|
||||
}
|
||||
return 0
|
||||
}
|
||||
42
internal/xair/util.go
Normal file
42
internal/xair/util.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package xair
|
||||
|
||||
import "math"
|
||||
|
||||
func mustDbInto(db float64) float64 {
|
||||
switch {
|
||||
case db >= 10:
|
||||
return 1
|
||||
case db >= -10:
|
||||
return float64((db + 30) / 40)
|
||||
case db >= -30:
|
||||
return float64((db + 50) / 80)
|
||||
case db >= -60:
|
||||
return float64((db + 70) / 160)
|
||||
case db >= -90:
|
||||
return float64((db + 90) / 480)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func mustDbFrom(level float64) float64 {
|
||||
switch {
|
||||
case level >= 1:
|
||||
return 10
|
||||
case level >= 0.5:
|
||||
return toFixed(float64(level*40)-30, 1)
|
||||
case level >= 0.25:
|
||||
return toFixed(float64(level*80)-50, 1)
|
||||
case level >= 0.0625:
|
||||
return toFixed(float64(level*160)-70, 1)
|
||||
case level >= 0:
|
||||
return toFixed(float64(level*480)-90, 1)
|
||||
default:
|
||||
return -90
|
||||
}
|
||||
}
|
||||
|
||||
func toFixed(num float64, precision int) float64 {
|
||||
output := math.Pow(10, float64(precision))
|
||||
return float64(math.Round(num*output)) / output
|
||||
}
|
||||
Reference in New Issue
Block a user