diff --git a/.gitignore b/.gitignore index 46627bc..711f276 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ *.pyc .vscode - +.DS_Store diff --git a/LICENSE b/LICENSE index 0439191..c1f387e 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,7 @@ MIT License -Copyright (c) 2018 Peter Dikant +Copyright (c) 2018,2021 Peter Dikant +Additions Copyright (c) 2021 Ross Dickson Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 88ea5a8..51ac0e4 100644 --- a/README.md +++ b/README.md @@ -1,45 +1,61 @@ # xair-remote -Use a Behringer X-Touch Mini MIDI controller to remote control a Behringer X-Air digital mixer via the OSC network protocol. Multiple layers allow you to control volume, mute and bus sends for every input and output channel with only 8 physical encoders. Changes on the mixer will also be displayed accurately on the X-Touch controller. +Use a Behringer X-Touch Mini MIDI controller to remote control a Behringer X-Air +digital mixer via the OSC network protocol. Multiple layers allow you to control +volume, mute and bus sends for every input and output channel with only 8 +physical encoders. Changes on the mixer will also be displayed accurately on the +X-Touch controller. ## Installing You need Python 3.5 or later. Please make sure to install required libraries: - $ sudo pip3 install -r requirements.txt + $ python3 -m pip install -r requirements.txt ## Update -If you update from a previous version, please make sure that you run at least Python 3.5 and install all required libraries as described in the previous section. +If you update from a previous version, please make sure that you run at least +Python 3.5 and install all required libraries as described in the previous +section. -The X-Touch device now needs to be running in MC mode. Make sure that the MC LED is lit after connecting the device by pressing the MC button during startup. +The X-Touch device needs to be running in MC mode. Make sure that the MC LED is +lit after connecting the device by pressing the MC button during startup. ## Running To get help run: - $ python3 xair-remote.py -h + $ python3 xair-remote.py -h -The app will automatically detect both your X-Touch controller and the XR18. So connect the controller and make sure the XR18 is reachable on your network. Now you can start the app: +The app will automatically detect both your X-Touch controller and the XR18. So +connect the controller and make sure the XR18 is reachable on your network. Now +you can start the app: - $ python3 xair-remote.py + $ python3 xair-remote.py -If the app can not find your controller or connect to the X-Air mixer, it will terminate with an error message explaining the problem. If everything started up successfully, the X-Touch mini will reflect the current mixer state and the console output will look like this: +If the app can not find your controller or connect to the X-Air mixer, it will +terminate with an error message explaining the problem. If everything started up +successfully, the X-Touch mini will reflect the current mixer state and the +console output will look like this: - Found XR18 with firmware 1.17 on IP 192.168.178.31 - Using MIDI input: X-TOUCH MINI - Using MIDI output: X-TOUCH MINI - Successfully connected to XR18 with firmware 1.17 at 192.168.178.31. + Found XR18 with firmware 1.17 on IP 192.168.178.31 + Using MIDI input: X-TOUCH MINI + Using MIDI output: X-TOUCH MINI + Successfully connected to XR18 with firmware 1.17 at 192.168.178.31. -Startup will fail if the X-Touch controller is not connected or the XR18 could not be located on the network. In the latter case you could try to specify the IP address of the mixer on the command line: +Startup will fail if the X-Touch controller is not connected or the XR18 could +not be located on the network. In the latter case you could try to specify the +IP address of the mixer on the command line: - $ python3 xair-remote.py 192.168.178.37 + $ python3 xair-remote.py 192.168.178.37 -The app can monitor the X-Touch connection and exit if the controller is disconnected. This functionality is enabled by setting the parameter `-m`: +The app can monitor the X-Touch connection and exit if the controller is +disconnected. This functionality is enabled by setting the parameter `-m`: - $ python3 xair-remote.py -m - -Note: Monitoring does not work on all platforms. Linux works fine while MacOS does not detect disconnects. + $ python3 xair-remote.py -m + +Note: Monitoring does not work on all platforms. Linux works fine while MacOS +does not detect disconnects. ## Using @@ -47,11 +63,75 @@ The following image is a schematic of all available controls on the X-Touch Mini ![X-Touch Mini controls](img/xtm-layout.png) -You need to configure the device to start in MC mode. If the MC mode LED is not lighted, hold down the MC button while connecting the device. +You need to configure the device to start in MC mode. If the MC mode LED is not +lighted, hold down the MC button while connecting the device. To exit press `CTRL + C`. -The folder `labels` contains labels to print and attach to your X-Touch as PDF and Excel files. You can change the labels to your specific setup. Make sure you print them in 100% size. +The folder `labels` contains labels to print and attach to your X-Touch as PDF +and Excel files. You can change the labels to your specific setup. Make sure you +print them in 100% size. + +### General Operation + +The general operation is fully defined in a config file in JSON format. Three +examples config files are provided and described below. The default is to use peterdikant.jason as it matches the operation of the original project. + +### Config File Format + +The config file uses JSON for easy parsing and reasonably easy editing. The file +is structured as a sequence of dictionaries each representing a "layer" that +defines all buttons, encoders, and the fader of the X-Touch Mini. Each layer +contains three dictionaries named 'encoders', 'buttons', and 'fader' that define +the respective elements of the X-Touch. Each control specifies the "channel" +that control effects and the command sent to the channel. The "channel" is +technically defined as an OSC address supported by the X-Air protocol that takes +an argument. The most common case in the X-Air command set is an address that is +a common prefix of a set of addresses defining how an audio source is to be +processed. Many aspects of the X-Air follow this pattern even though they are +aspects of the mixer that would not be considered a channel in an analog mixer. +In a large number of cases the channel not only has a level in the main mix but +also a level on each of the auxiliary bus or effects processor the source can +'send' to. + +The **encoders** dictionary is a list of 8 lists each specifying the operation of +one encoder from `E1` to `E8`. These lists contain two elements: + +* channel, one of: + * an X-AIR OSC address + * 'none' - if turning the encoder is to have no effect +* a list defining the function of pressing the encoder as a button supporting + three commands: + * 'none' - if pressing the encoder has no effect + * 'mute' followed by a 'channel' and 'send' to mute + * 'reset' followed by a 'value' to set the encoder channel to + * 'subprocess' - as the first element of a list defining a set of external + commands to cycle through formatted as ['subprocess', 'external call', + ['command', 'command', ...]] + +The **buttons** dictionary is a list of 18 lists each specifying the operation of +one button from `B01` to `B16` followed by `LA` and `LB`. These lists contain a +variable number of elements based on the command: + +* 'none' followed by a 'comment' which defines if the button LED is to be on or off +* 'mute' which is followed by the 'channel' to mute, the light matches the mute state +* 'send' followed by number of the send with 0 defined as the main mix for this layer +* 'quit' followed by a 'comment' which defines if the button LED is on or off +* 'layer' followed by the destination 'layer' the max 'send level' and the LED status +* 'clip' followed by the LED state +* 'tap' followed by the LED state + +The **fader** dictionary is a list of one list specifying the operation of the +fader. The list is formatted the same as an encoder without the press function +or 'quit' if setting the fader to 100% quits. + +## Example Configuraitons + +### peterdikant.json + +This file configures the X-Touch to operate as defined by Peter Dikant to +support live sound mixing. The description is his original definition of the +xair-remote and thus does not match the organization of the config file. ### Layer A @@ -118,6 +198,73 @@ Layer | Encoders (Volume) 3 | Aux, 7 unassigned The Push function of encoders `E1` to `E4` can be used to toggle mute groups 1 to 4 in all fader banks. +### rossdickson.json + +This file configures the X-Touch to operate as defined by Ross Dickson to +support live sound mixing. The basic concept of the design is to conceive of the +encoders `E1` through `E8` and top row of buttons `B01` through `B08` as a set +of channel strips where the each encoder adjusts a channel level on turn, resets +the level on press, and the button mutes the channel. The rings around the +encoders and lights of the mute buttons show the values of the selected +channels. The lower row of buttons and the layer buttons control what channels +the channel strips control. + +The Fader `F1` is used used as a master quit signal when set to the top. + +### Layer A and A' + +Pressing `LA` configures the channel strips to control the first 8 channels and +is indicated by a lighted button. The reset value is the equivalent of the 0db +level on a fader. Pressing `LA` again switches to the second 8 channels and is +indicated by a blinking button. Further presses toggle between the two. + +Buttons, `B09` through `B15`, the first 7 buttons of the lower row define which +'send' bus is being controlled by the channel strips. When no lights are lit the +encoders control the main LR mix bus. Pressing a send button switches to the +corresponding aux bus and the first FX processor. Pressing the same send agains +reverts to the main LR Mix. + +### Layer B + +Pressing `LB` configures the channel strips to control the output levels on the +6 AUX ports, the level of the USB return, and the main LR output level. The +reset level is -20db for outputs and 0db for the USB return. -20db is used as it +is about 6db lower than max level for something assuming consumer audio line +level rather than pro audio line levels and thus is generally safe no matter +what sort of amp or speakers you might be driving. + +Buttons, `B09` through `B15`, are unused as there are no "sends" for outputs. + +### Layer `Gain` and `Gain'` + +Pressing `B16` configured the channel strips to control the gain levels of the +mic preamps on the first 8 channels and is indicated by a lighted button. +Similar to `LA` pressing `E16` again toggles to the second 8 mic preamps. As +preamps have neither mute nor sends most of the buttons are unused though a few +are used to control additional functions, `B03` and `B04` blink and are used to +quit while `B07` also blinks and is used to enable the clipping protection +function. When enabled, any channel that has more than 3 sequential samples +within 3db of max it will lower the gain of the mic preamp by on step out of a +16 bit range. + +Somewhat graphically this configures the X-Touch as: + + + +1/9 | 2/10 | ... | 5/14 | 7/15 | 8/16 | | +--- | ---- | --- | ---- | ---- | ---- | --- | +Mute | Mute | ... | Mute | Mute | Mute | Mix Channels | +AUX 1| AUX 2| ... | AUX 6| Fx 1 | Pre | Outputs | + +### simple.json + +This file configures the X-Touch to operate as a simple set of levels and mutes +for the X-Air to support a "dumb mixer" install. The basic concept of the design +is to conceive of the encoders `E1` through `E8` as levels and the buttons `B01` +through `B16` as a mutes for the 16 channels of the X-Air. The buttons `LA` and +`LB` switch the levels to match the upper or lower row of buttons. The rings +around the encoders show the levels while pressing the encoders returns the level +to 0db to quickly reset the mixer. The main fader `F1` is not used. ## License diff --git a/lib/midicontroller.py b/lib/midicontroller.py index f610ea8..aa65603 100644 --- a/lib/midicontroller.py +++ b/lib/midicontroller.py @@ -1,7 +1,12 @@ +"See MidiController docstring" +# part of xair-remote.py +# Copyright (c) 2018, 2021 Peter Dikant +# Additions Copyright (c) 2021 Ross Dickson +# Some rights reserved. See LICENSE. + import threading import time import os -from .mixerstate import MixerState from mido import Message, open_input, open_output, get_input_names, get_output_names class TempoDetector: @@ -9,18 +14,19 @@ class TempoDetector: Detect song tempo via a tap button """ _MAX_TAP_DURATION = 3.0 - current_tempo = 0.5 - - def __init__(self, midi_controller): - self.midi_controller = midi_controller + number = -1 + + def __init__(self, state): + self.state = state + self.midi_controller = state.midi_controller self.last_tap = 0 self.tap_num = 0 self.tap_delta = 0 worker = threading.Thread(target = self.blink) worker.daemon = True worker.start() - + def tap(self): current_time = time.time() if current_time - self.last_tap > self._MAX_TAP_DURATION: @@ -32,18 +38,21 @@ def tap(self): self.tap_delta += current_time - self.last_tap if self.tap_num > 0: # Update tempo in mixer after at least 2 taps - self.midi_controller.update_tempo(self.tap_delta / self.tap_num, True) + self.state.update_tempo(self.tap_delta / self.tap_num) self.current_tempo = self.tap_delta / self.tap_num self.last_tap = current_time - + def blink(self): try: - while True: - self.midi_controller.tempo_led(True) - time.sleep(self.current_tempo * 0.2) - self.midi_controller.tempo_led(False) - time.sleep(self.current_tempo * 0.8) + while self.state is not None and self.state.quit_called != True: + if self.number != -1: + self.midi_controller.set_channel_mute(self.number, "On") + time.sleep(self.current_tempo * 0.2) + self.midi_controller.set_channel_mute(self.number, "Off") + time.sleep(self.current_tempo * 0.8) except KeyboardInterrupt: + if self.state is not None: + self.state.shutdown() exit() class MidiController: @@ -51,35 +60,33 @@ class MidiController: Handles communication with the MIDI surface. X-Touch Mini must be in MC mode! - LA/LB: Note 84/85 Fader 1-8: CC16 - CC23, Note 32 - 39 on push Turn right: Values 1 - 10 (Increment) Turn left: Values 65 - 72 (Decrement) - Buttons 1-8: Note 89, 90, 40, 41, 42, 43, 44, 45 + Buttons 1-8: Note 89, 90, 40, 41, 42, 43, 44, 45 Buttons 9-16: Note 87, 88, 91, 92, 86, 93, 94, 95 + Buttons LA/LB (aka 17/18): Note 84/85 Master Fader: Pitch Wheel """ MC_CHANNEL = 0 - MIDI_BUTTONS = [89, 90, 40, 41, 42, 43, 44, 45, 87, 88, 91, 92, 86, 93, 94, 95] + MIDI_BUTTONS = [89, 90, 40, 41, 42, 43, 44, 45, 87, 88, 91, 92, 86, 93, 94, 95, 84, 85] MIDI_PUSH = [32, 33, 34, 35, 36, 37, 38, 39] MIDI_ENCODER = [16, 17, 18, 19, 20, 21, 22, 23] MIDI_RING = [48, 49, 50, 51, 52, 53, 54, 55] - MIDI_LAYER = [84, 85] LED_OFF = 0 LED_BLINK = 1 LED_ON = 127 active_layer = 0 - active_bus = 0 inport = None outport = None - + def __init__(self, state): self.state = state - + for name in get_input_names(): if "x-touch mini" in name.lower(): print('Using MIDI input: ' + name) @@ -87,7 +94,9 @@ def __init__(self, state): self.inport = open_input(name) except IOError: print('Error: Can not open MIDI input port ' + name) - exit() + self.state.quit_called = True + self.state = None + return break for name in get_output_names(): @@ -97,190 +106,150 @@ def __init__(self, state): self.outport = open_output(name) except IOError: print('Error: Can not open MIDI input port ' + name) - exit() + self.state.quit_called = True + self.state = None + return break - + if self.inport is None or self.outport is None: print('X-Touch Mini not found. Make sure device is connected!') - exit() + self.state.quit_called = True + self.cleanup_controller() + return - self.tempo_detector = TempoDetector(self) - self.change_layer(0) - self.activate_bank(0) - #self.activate_bus(0) + for i in range(0, 18): + self.set_button(i, self.LED_OFF) # clear all buttons - worker = threading.Thread(target = self.midi_listener) + worker = threading.Thread(target=self.midi_listener) worker.daemon = True worker.start() + if self.state.monitor: + print('Monitoring X-Touch connection enabled') + monitor = threading.Thread(target=self.monitor_ports) + monitor.daemon = True + monitor.start() + + def cleanup_controller(self): + "Cleanup mixer state if we see a quit call. Called from _init_ or worker thread." + for i in range(0, 18): + self.set_button(i, self.LED_OFF) # clear all buttons + for i in range(0,8): + self.set_ring(i,-1) + if self.inport is not None: + self.inport.close() + if self.outport is not None: + self.outport.close() + def monitor_ports(self): + "Method to exit if / when the X Touch disconnects" try: - while True: + while not self.state.quit_called: if self.inport.name not in get_input_names(): print("X-Touch disconnected - Exiting") - os._exit(1) + #os._exit(1) + self.state.quit_called = True + return + if self.state.quit_called: + return # end the thread if other threads have signed exit time.sleep(1) except KeyboardInterrupt: + if self.state is not None: + self.state.shutdown() + else: + self.cleanup_controller() exit() def midi_listener(self): + "Listen to midit inputs and respond." try: for msg in self.inport: + if self.state is None or self.state.quit_called: + self.cleanup_controller() + return #print('Received {}'.format(msg)) if msg.type == 'control_change': if msg.control in self.MIDI_ENCODER: delta = msg.value if delta > 64: delta = (delta - 64) * -1 - if self.active_layer == 0: - self.state.change_fader(self.MIDI_ENCODER.index(msg.control), delta) - else: - self.state.change_bus_send(self.active_bus, self.MIDI_ENCODER.index(msg.control), delta) + encoder_num = self.MIDI_ENCODER.index(msg.control) + LED = self.state.encoder_turn(encoder_num, delta) + self.set_ring(encoder_num, LED) else: print('Received unknown {}'.format(msg)) elif msg.type == 'note_on' and msg.velocity == 127: + if self.state.debug: + print('Note {} pushed'.format(msg.note)) if msg.note in self.MIDI_PUSH: - self.knob_pushed(self.MIDI_PUSH.index(msg.note)) + encoder_num = self.MIDI_PUSH.index(msg.note) + LED = self.state.encoder_press(encoder_num) + self.set_ring(encoder_num, LED) elif msg.note in self.MIDI_BUTTONS: - self.button_pushed(self.MIDI_BUTTONS.index(msg.note)) - elif msg.note in self.MIDI_LAYER: - self.change_layer(self.MIDI_LAYER.index(msg.note)) + button_num = self.MIDI_BUTTONS.index(msg.note) + LED = self.state.button_press(button_num) + self.set_channel_mute(button_num, LED) else: print('Received unknown {}'.format(msg)) elif msg.type == 'pitchwheel': - value = (msg.pitch + 8192) / 16384 - self.state.set_lr_fader(value) + self.state.fader_move(msg) elif msg.type != 'note_off' and msg.type != 'note_on': print('Received unknown {}'.format(msg)) + if self.state.quit_called: + self.state.shutdown() + return except KeyboardInterrupt: - self.inport.close() - self.outport.close() - exit() - - def button_pushed(self, button): - if button == 8: - # mute grp 4 pressed - self.state.toggle_mute_group(3) - elif button == 9: - # tempo button pressed - self.tempo_detector.tap() - elif button != 10: - if self.active_layer == 0: - if button >= 11 and button <= 15: - # bank select buttons pressed - self.activate_bank(button - 11) - else: - # mute buttons pressed - self.state.toggle_channel_mute(button) + if self.state is not None: + self.state.shutdown() else: - if button >= 11 and button <= 13: - # bank select buttons pressed - self.activate_bank(button - 11) - else: - self.active_bus = button if button < 8 else button - 6 - self.refresh_controls(self.state.active_bank) - - def knob_pushed(self, knob): - if knob >= 0 and knob <= 3: - self.state.toggle_mute_group(knob) - elif knob == 7: - self.state.toggle_mpc() + self.cleanup_controller() + exit() - def activate_bank(self, bank): - #print("Switching to fader bank %d" % (bank + 1)) - for i in range(0, 5): - if i == bank: - self.set_button(11 + i, True) - else: - self.set_button(11 + i, False) - if self.state.active_bank != bank: - self.refresh_controls(bank) - self.state.active_bank = bank - - def change_layer(self, layer): - if layer == 0: - self.outport.send(Message('note_on', channel = self.MC_CHANNEL, note = self.MIDI_LAYER[0], velocity = self.LED_ON)) - self.outport.send(Message('note_on', channel = self.MC_CHANNEL, note = self.MIDI_LAYER[1], velocity = self.LED_OFF)) - self.activate_bank(self.state.active_bank) - else: - self.outport.send(Message('note_on', channel = self.MC_CHANNEL, note = self.MIDI_LAYER[0], velocity = self.LED_OFF)) - self.outport.send(Message('note_on', channel = self.MC_CHANNEL, note = self.MIDI_LAYER[1], velocity = self.LED_ON)) - if self.state.active_bank > 2: - self.activate_bank(2) - self.active_layer = layer - self.refresh_controls(self.state.active_bank) - - def refresh_controls(self, bank): - if self.active_layer == 0: - for i in range(0, 8): - if self.state.banks[bank][i] != None: - # send fader for Layer A - self.set_ring(i, self.state.banks[bank][i].fader) - self.set_channel_mute(i, self.state.banks[bank][i].on) - else: - # unassigned channel, disbale encoder and button - self.set_ring(i, -1) - self.set_button(i, False) + def activate_bus(self): + "refresh the lights for the current layer" + # reset lights + for i in range(0, 8): + self.set_ring(i, self.state.get_encoder(i)) + self.set_channel_mute(i, self.state.get_button(i)) + for i in range(8, 18): + self.set_channel_mute(i, self.state.get_button(i)) + + def set_channel_mute(self, channel, LED): + "Send the mute value to the button" + if LED == "On" or LED == 0: # LED is sense Negative for mute + self.set_button(channel, self.LED_ON) + elif LED == "Off" or LED == 1: # LED is sense Negative for mute + self.set_button(channel, self.LED_OFF) + elif LED == "none": + return else: - for i in range(0, 10): - if i < 8: - btn = i - else: - # sends 9 & 10 need other button numbers - btn = i + 6 - if i != self.active_bus: - self.set_button(btn, False) - else: - self.set_button(btn, True) - for i in range(0, 8): - if self.state.banks[bank][i] != None and self.state.banks[bank][i].sends != None: - self.set_ring(i, self.state.banks[bank][i].sends[self.active_bus]) - else: - self.set_ring(i, -1) - # set indicator for mute group 4 - self.set_mute_grp(3, self.state.mute_groups[3].on) - - def set_mute_grp(self, group, on): - if group == 3: - # we only need to update this group as all others have no led indicator - self.set_button(8, on == 1) - - def set_channel_mute(self, channel, on): - if self.active_layer == 0: - self.set_button(channel, on == 0) - - def set_channel_fader(self, channel, value): - if self.active_layer == 0: - self.set_ring(channel, value) - - def set_bus_send(self, bus, knob, value): - if self.active_layer == 1 and self.active_bus == bus: - self.set_ring(knob, value) + self.set_button(channel, self.LED_BLINK) def set_ring(self, ring, value): - # 0 = off, 1-11 = single, 17-27 = trim, 33-43 = fan, 49-54 = spread + "Send the fader value to the encoder ring" + "Turn on the appropriate LEDs on the encoder ring." + # 0 = off, 1-11 = single, 17-27 = pan, 33-43 = fan, 49-54 = spread # normalize value (0.0 - 1.0) to 0 - 11 range # values below 0 mean disabled if value >= 0.0: - self.outport.send(Message('control_change', channel = self.MC_CHANNEL, - control = self.MIDI_RING[ring], - value = 33 + round(value * 11))) - else: - self.outport.send(Message('control_change', channel = self.MC_CHANNEL, - control = self.MIDI_RING[ring], - value = 0)) - - def set_button(self, button, on): - if on == True: - self.outport.send(Message('note_on', channel = self.MC_CHANNEL, note = self.MIDI_BUTTONS[button], velocity = self.LED_ON)) - else: - self.outport.send(Message('note_on', channel = self.MC_CHANNEL, note = self.MIDI_BUTTONS[button], velocity = self.LED_OFF)) - - def tempo_led(self, on): - self.set_button(9, on) - - def update_tempo(self, tempo, detected = False): - if detected == True: - self.state.update_tempo(tempo) + self.outport.send(Message('control_change', channel=self.MC_CHANNEL, + control=self.MIDI_RING[ring], + value=self.map_lights(value))) +# value=33 + round(value * 11))) else: - self.tempo_detector.current_tempo = tempo + self.outport.send(Message('control_change', channel=self.MC_CHANNEL, + control=self.MIDI_RING[ring], + value=0)) + + def map_lights(self, value): + "map the (0:1) range of fader values to ring light patterns" + # for faders -oo to -10.2db map to single lights while -10 to +10 map to the fan + value = 1 + round(value * 21) + if value > 11: + value = value + 22 + return value + + def set_button(self, button, ch_on): + "Turn the button LED on or off" + self.outport.send(Message('note_on', channel=self.MC_CHANNEL, + note=self.MIDI_BUTTONS[button], velocity=ch_on)) diff --git a/lib/mixerstate.py b/lib/mixerstate.py index 160adc4..994e0c4 100644 --- a/lib/mixerstate.py +++ b/lib/mixerstate.py @@ -1,23 +1,278 @@ +"This module holds the mixer state of the X-Air device" +# part of xair-remote.py +# Copyright (c) 2018, 2021 Peter Dikant +# Additions Copyright (c) 2021 Ross Dickson +# Some rights reserved. See LICENSE. + import time import subprocess - -""" -This module holds the mixer state of the X-Air device -""" +import struct +import json +from collections import deque +from lib.xair import XAirClient, find_mixer +from lib.midicontroller import MidiController, TempoDetector class Channel: - """ - Represents a single channel strip + """ + Represents a single channel or bus """ def __init__(self, addr): - self.fader = 0.0 - if addr.startswith('/ch') or addr.startswith('/rtn'): - self.sends = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] - else: - self.sends = None - self.on = 1 + # LR then 6 aux bus followed by the 4 effects + self.sends = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] + self.enables = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] self.osc_base_addr = addr - + + def get_m_addr(self, bus): + if bus == 0: + if self.osc_base_addr.startswith('/config'): + return(self.osc_base_addr) + else: + return(self.osc_base_addr + "/on") + else: + return(self.osc_base_addr + '/{:0>2d}/level'.format(bus)) + + def get_l_addr(self, bus): + if bus == 0: + if self.osc_base_addr.startswith('/head'): + return(self.osc_base_addr + "/gain") + else: + return(self.osc_base_addr + "/fader") + else: + return(self.osc_base_addr + '/{:0>2d}/level'.format(bus)) + + def toggle_mute(self, bus): + """Toggle a mute on or off.""" +# print("toggle mute of %s bus %s from %s" % (self.get_m_addr(bus), bus, self.enables[bus])) + if self.enables[bus] == 1: + self.enables[bus] = 0 + param=0.0 + else: + self.enables[bus] = 1 + param=self.sends[bus] + if bus == 0: + return(self.get_m_addr(bus), self.enables[bus], self.enables[bus]) + else: + return(self.get_m_addr(bus), param, self.enables[bus]) + + def set_mute(self, bus, value): + """Set the state of the channel mute.""" + self.enables[bus] = value + return(self.get_m_addr(bus), self.enables[bus], self.enables[bus]) + + def get_mute(self, bus): + return(self.enables[bus]) + + def change_level(self, bus, delta): + """Change the level of a fader, mic pre, or bus send.""" +# print("changing %s bus %s by %s" % (self.osc_base_addr, bus, delta)) + if bus == "gain": + bus = 0 + self.sends[bus] = min(max(0.0, self.sends[bus] + (delta / 200)), 1.0) + return(self.get_l_addr(bus), self.sends[bus], self.sends[bus]) + + def set_level(self, bus, value): + """Set the level of a fader, mic pre, or bus send.""" + if bus == "gain": + bus = 0 + self.sends[bus] = value + return(self.get_l_addr(bus), + self.sends[bus], self.sends[bus]) + + def get_level(self, bus): + """Return the current level of a fader, mic pre, or bus send.""" + if bus == "gain": + bus = 0 + return(self.sends[bus]) + +class SubProc: + """ + Manages a subprocess + """ + def __init__(self, call_type, proc_name, args) -> None: + self.call_type = call_type + self.proc_name = proc_name + self.args = args + self.max = len(args) + self.current = 0 + + def toggle(self): + if self.call_type == "subprocess": + try: + subprocess.call([self.proc_name, self.args[self.current]]) + except OSError: + pass + self.current += (self.current + 1) % self.max + +# the config json file specifies a number of layers each idendified by a name +# within the layer there are currently two sections: encoders and buttons +# there are 8 encoders per section with three parts: channel, send/fader/gain, button + +# fader, channel, [button] +# gain, channel +# level, channel, bus, [button] +# There are 18 buttons per section to types: lengths +button_def = {'quit': 2, 'none': 2, 'layer': 4, 'clip': 2, 'mute': 2, 'tap': 2, "send": 2} +button_types = set(button_def.keys()) + +class Layer: + """ + Represents a logical layer as defined by the config file + """ + encoders = [] + buttons = [] + faders = [] + channels = {} + proc_list = {} + active_bus = 0 + + def __init__(self, layer_name, config_layer, channels, layer_names, proc_list) -> None: + "Initialize a Layer" + # first check for gross configurations errors + number = len(config_layer.keys()) + if number > 3: + print("Warning: expected exactly three types of controls %d found" % number) + + # Process the encoders + self.encoders = config_layer["encoders"] + if len(self.encoders) != 8: + print("Layer %s does not contain 8 'encoder' definitions." % layer_name) + for encoder in self.encoders: + if len(encoder) != 2: + print("Error: Encoder %s of layer %s does not contain 2 elements, exiting." % \ + (encoder[0], layer_name)) + exit() + if encoder[0] != "none" and encoder[0] not in channels.keys(): + channels[encoder[0]] = Channel(encoder[0]) + if encoder[1][0] == "mute" and encoder[1][1] not in channels.keys(): + channels[encoder[1][1]] = Channel(encoder[1][1]) + elif encoder[1][0] == "subprocess" and encoder[1][1] not in proc_list.keys(): + proc_list[encoder[1][1]] = SubProc(encoder[1][0], encoder[1][1], encoder[1][2]) + + # Process the buttons + self.buttons = config_layer["buttons"] + if len(self.buttons) != 18: + print("Layer %s does not contain 18 'button' definitions." % layer_name) + for button in self.buttons: + if button[0] not in button_types: + print("Layer %s contains an unknown 'button' %s." % \ + (layer_name, button[0])) + exit() + if len(button) != button_def[button[0]]: + print("Error: Button %s of layer %s does not contain %d elements" % \ + (button[0], layer_name, button_def[button[0]])) + if button[0] == "layer" and button[1] not in layer_names: + print("Error: Layer %s has a change to undefined layer %s." % \ + (layer_name, button[1])) + if button[0] == "mute" and button[1] not in channels.keys(): + channels[button[1]] = Channel(button[1]) + + # Process the fader + self.faders = config_layer["fader"] + if len(self.faders) != 1: + print("Layer %s does not contain 1 'fader' definitions." % layer_name) + for fader in self.faders: + if len(fader) != 2: + print("Error: Fader %s of layer %s does not contain 2 elements, exiting." % \ + (fader[0], layer_name)) + exit() + if fader[0] != "none" and fader[0] != "quit" and fader[0] not in channels.keys(): + channels[fader[0]] = Channel(fader[0]) + + self.channels = channels + self.proc_list = proc_list + + def encoder_turn(self, number, value): + encoder = self.encoders[number] + if encoder[0] == "none": + return(None, None, 0.0) + return(self.channels[encoder[0]].change_level(self.active_bus, value)) + + def encoder_press(self, number): + encoder = self.encoders[number] + if encoder[1][0] == "reset": + return(self.channels[encoder[0]].set_level(self.active_bus, float(encoder[1][1]))) + elif encoder[1][0] == "mute": + (address, param, LED) = self.channels[encoder[1][1]].toggle_mute(int(encoder[1][2])) + return(address, param, self.channels[encoder[0]].get_level(self.active_bus)) + elif encoder[1][0] == "subprocess": + self.proc_list[encoder[1][1]].toggle() + return(None, None, self.channels[encoder[0]].get_level(self.active_bus)) + if encoder[0] == "none": + return(None, None, 0.0) + return(None, None, self.channels[encoder[0]].get_level(self.active_bus)) + + def encoder_state(self, number): + encoder = self.encoders[number] + if encoder[0] == "none": + return 0.0 + return(self.channels[encoder[0]].get_level(self.active_bus)) + + def toggle_button(self, number): + button = self.buttons[number] + if button[0] == "mute": + return (self.channels[button[1]].toggle_mute(self.active_bus)) + if button[0] == "layer": + max_bus = int(button[2]) + return ("layer", button[1], max_bus if self.active_bus > max_bus else self.active_bus) + if button[0] == "send": + if self.active_bus == int(button[1]): + self.active_bus = 0 + return ("send", None, "Off") + else: + self.active_bus = int(button[1]) + return ("send", None, "On") + # return the relevant action for the non mixer based button + return (button[0], button[1], button[-1]) + + def button_state(self, number): + button = self.buttons[number] + if button[0] == "mute": + return(self.channels[button[1]].get_mute(self.active_bus)) + elif button[0] == "send": + return("On" if self.active_bus == int(button[1]) else "Off") + else: + return(button[-1]) + + def fader_move(self, value): + if self.faders[0][0] == "quit": + return("quit", "none", "none") + if self.faders[0][0] == "none": + return("none", "none", "none") + else: + return(self.channels[self.faders[0][0]].set_level(int(self.faders[0][1]), value)) + + def encoder_number(self, name): + number = 0 + for encoder in self.encoders: + if name == encoder[0]: + return number + number += 1 + return -1 + + def button_number(self, name): + number = 0 + for button in self.buttons: + if len(button) > 1 and name == button[1]: + return number + number += 1 + return -1 + +class Meter: + """ + Calculates the .2 second running average of the value of a channel meter + """ + values = 4 + def __init__(self): + self.levels = deque(maxlen=self.values) + for _ in range(self.values): + self.levels.append(-102400) + self.mean = -102400 * self.values + + def insert_level(self, value): + 'push a vlue into the fixed FIFO and update the mean' + self.mean = self.mean - self.levels.popleft() + value + self.levels.append(value) + return self.mean class MixerState: """ @@ -26,193 +281,273 @@ class MixerState: decide whether state changes from the X-Air device need to be sent to the midi controller. """ - + + quit_called = False + layers = {} + channels = {} + proc_list = {} + current_layer = None + # ID numbers for all available delay effects _DELAY_FX_IDS = [10, 11, 12, 21, 24, 25, 26] fx_slots = [0, 0, 0, 0] - - active_bank = -1 - - # Each layer has 8 encoders and 8 buttons - banks = [ - [ - Channel('/ch/01/mix'), - Channel('/ch/02/mix'), - Channel('/ch/03/mix'), - Channel('/ch/04/mix'), - Channel('/ch/05/mix'), - Channel('/ch/06/mix'), - Channel('/ch/07/mix'), - Channel('/ch/08/mix') - ], - [ - Channel('/ch/09/mix'), - Channel('/ch/10/mix'), - Channel('/ch/11/mix'), - Channel('/ch/12/mix'), - Channel('/ch/13/mix'), - Channel('/ch/14/mix'), - Channel('/ch/15/mix'), - Channel('/ch/16/mix') - ], - [ - Channel('/rtn/aux/mix'), - None, - None, - None, - Channel('/dca/1'), - Channel('/dca/2'), - Channel('/dca/3'), - Channel('/dca/4') - ], - [ - Channel('/fxsend/1/mix'), - Channel('/fxsend/2/mix'), - Channel('/fxsend/3/mix'), - Channel('/fxsend/4/mix'), - Channel('/rtn/1/mix'), - Channel('/rtn/2/mix'), - Channel('/rtn/3/mix'), - Channel('/rtn/4/mix') - ], - [ - Channel('/bus/1/mix'), - Channel('/bus/2/mix'), - Channel('/bus/3/mix'), - Channel('/bus/4/mix'), - Channel('/bus/5/mix'), - Channel('/bus/6/mix'), - None, - None - ] - ] lr = Channel('/lr/mix') - mute_groups = [ - Channel('/config/mute/1'), - Channel('/config/mute/2'), - Channel('/config/mute/3'), - Channel('/config/mute/4') - ] - mpd_playing = True - + midi_controller = None xair_client = None + tempo_detector = None - def toggle_mute_group(self, group): - if self.mute_groups[group].on == 1: - self.mute_groups[group].on = 0 - else: - self.mute_groups[group].on = 1 - self.xair_client.send(address = self.mute_groups[group].osc_base_addr, - param = self.mute_groups[group].on) - self.midi_controller.set_mute_grp(group, self.mute_groups[group].on) - - def toggle_channel_mute(self, channel): - if self.banks[self.active_bank][channel] != None: - if self.banks[self.active_bank][channel].on == 1: - self.banks[self.active_bank][channel].on = 0 - else: - self.banks[self.active_bank][channel].on = 1 - self.xair_client.send(address = self.banks[self.active_bank][channel].osc_base_addr + '/on', - param = self.banks[self.active_bank][channel].on) - self.midi_controller.set_channel_mute(channel, self.banks[self.active_bank][channel].on) - - def toggle_mpc(self): - if self.mpd_playing: - try: - subprocess.call(['mpc', 'pause']) - except OSError: - pass - self.mpd_playing = False - else: - try: - subprocess.call(['mpc', 'play']) - except OSError: - pass - self.mpd_playing = True + meters = [] + for i in range(16): + meters.append(Meter()) + + def __init__(self, args) -> None: + # split the arguments out to useful values + self.debug = args.debug + self.xair_address = args.xair_address + self.monitor = args.monitor + self.clip = args.clip + self.mac = False # args.mac + self.levels = args.levels + + # initialize internal data structures + config_json = "peterdikant.json" + if args.config_file is not None: + config_json = args.config_file[0] + with open(config_json) as config_file: + config = json.load(config_file) + + layer_names = config.keys() - def change_fader(self, fader, delta): - if self.banks[self.active_bank][fader] != None: - self.banks[self.active_bank][fader].fader = min(max(0.0, self.banks[self.active_bank][fader].fader + (delta / 200)), 1.0) - self.xair_client.send(address = self.banks[self.active_bank][fader].osc_base_addr + '/fader', - param = self.banks[self.active_bank][fader].fader) - self.midi_controller.set_channel_fader(fader, self.banks[self.active_bank][fader].fader) + for layer_name in layer_names: + if self.current_layer == None: + self.current_layer = layer_name + self.layers[layer_name] = Layer(layer_name, config[layer_name], + self.channels, layer_names, self.proc_list) - def change_bus_send(self, bus, channel, delta): - if self.banks[self.active_bank][channel] != None and self.banks[self.active_bank][channel].sends != None: - self.banks[self.active_bank][channel].sends[bus] = min(max(0.0, self.banks[self.active_bank][channel].sends[bus] + (delta / 200)), 1.0) - self.xair_client.send(address = self.banks[self.active_bank][channel].osc_base_addr + '/{:0>2d}/level'.format(bus + 1), - param = self.banks[self.active_bank][channel].sends[bus]) - self.midi_controller.set_bus_send(bus, channel, self.banks[self.active_bank][channel].sends[bus]) + def initialize_state(self): + self.quit_called = False + # determine the mixer address + if self.xair_address is None: + self.xair_address = find_mixer() + if self.xair_address is None: + print('Error: Could not find any mixers in network.', + 'Using default ip address.') + self.xair_address = "192.168.50.146" - def set_lr_fader(self, value): - self.xair_client.send(address = self.lr.osc_base_addr + '/fader', param = value) + # setup other modules + self.midi_controller = MidiController(self) + if self.quit_called: + self.midi_controller = None + return False + self.xair_client = XAirClient(self.xair_address, self) + self.xair_client.validate_connection() + if self.quit_called: + self.midi_controller = None + self.xair_client = None + return False + self.tempo_detector = TempoDetector(self) + + self.read_initial_state() + self.midi_controller.activate_bus() + self.tempo_detector.number = self.layers[self.current_layer].button_number("tap") + return True + + def shutdown(self): + self.quit_called = True + "safely shutdown all threads" + if self.xair_client is not None: + self.xair_client.stop_server() + self.xair_client = None + if self.midi_controller is not None: + self.midi_controller.cleanup_controller() + self.midi_controller = None +# if self.screen_obj is not None: +# self.screen_obj.quit() + + def button_press(self, number): + """Handle a button press.""" + if self.debug: + print('Button %d pressed' % number) + (address, param, LED) = self.layers[self.current_layer].toggle_button(number) + if address == 'layer': + self.current_layer = param + self.layers[self.current_layer].active_bus = LED + self.midi_controller.activate_bus() + self.tempo_detector.number = self.layers[self.current_layer].button_number("tap") + return self.get_button(number) + elif address == 'send': + self.midi_controller.activate_bus() + return LED + elif address == 'clip': + self.clip = not self.clip + return "On" if self.clip else "Off" + elif address == 'quit': + self.shutdown() + exit() + elif address == 'record': + pass + return "Off" + elif address == 'tap': + self.tempo_detector.tap() + return "none" + if address != None: + if address.startswith('/config/mute'): + param = 1 if param == 0 else 0 + self.xair_client.send(address=address, param=param) + return LED + + def get_button(self, number): + if self.debug: + print('Getting state of button number %d' % number) + return(self.layers[self.current_layer].button_state(number)) + +# def mac_button(self, button): +# "call a function for transport buttons on mac" +# if button == 10: +# os.system("""osascript -e 'tell application "music" to previous track'""") +# elif button == 11: +# os.system("""osascript -e 'tell application "music" to next track'""") +# elif button == 12: +# self.state.shutdown() +# self.cleanup_controller() +# exit() +# elif button == 13: +# os.system("""osascript -e 'tell application "music" to pause'""") +# elif button == 14: +# os.system("""osascript -e 'tell application "music" to play'""") + + def encoder_turn(self, number, delta): + """Change the level of an encoder.""" + (address, param, LED) = self.layers[self.current_layer].encoder_turn(number, delta) + if address != None: + self.xair_client.send(address=address, param=param) + return LED + + def encoder_press(self, number): + (address, param, LED) = self.layers[self.current_layer].encoder_press(number) + if address != None: + print("sending %s %s" % (address, param)) + if address.startswith('/config/mute'): + invert = 1 if param == 0 else 0 + self.xair_client.send(address=address, param=invert) + number = self.layers[self.current_layer].button_number(address) + if number != -1: + self.midi_controller.set_channel_mute(number, param) + else: + self.xair_client.send(address=address, param=param) + return LED + + def get_encoder(self, number): + if self.debug: + print('Getting state of encoder number %d' % number) + return(self.layers[self.current_layer].encoder_state(number)) + + def fader_move(self, msg): + value = (msg.pitch + 8192) / 16384 + if self.debug: + print('Wheel set to {}'.format(msg)) + (address, param, LED) = self.layers[self.current_layer].fader_move(value) + if address == "quit": + if value > .98: + self.shutdown() + exit() + elif address != 'none': + self.xair_client.send(address=address, param=param) def received_osc(self, addr, value): - if addr.startswith('/config/mute'): - group = int(addr[-1:]) - 1 - self.mute_groups[group].on = value - self.midi_controller.set_mute_grp(group, value) - elif addr.startswith('/fx') and (addr.endswith('/par/01') or addr.endswith('/par/02')): - if self.fx_slots[int(addr[4:5]) - 1] in self._DELAY_FX_IDS: - self.midi_controller.update_tempo(value * 3) - elif addr.startswith('/fx/') and addr.endswith('/type'): - self.fx_slots[int(addr[4:5]) - 1] = value - if value in self._DELAY_FX_IDS: - # slot contains a delay, get current time value - param_id = '01' - if value == 10: - param_id = '02' - self.xair_client.send(address = '/fx/%s/par/%s' % (addr[4:5], param_id)) - else: - for i in range(0, 5): - for j in range(0, 8): - if self.banks[i][j] != None and addr.startswith(self.banks[i][j].osc_base_addr): - if addr.endswith('/fader'): - self.banks[i][j].fader = value - if i == self.active_bank: - self.midi_controller.set_ring(j, value) - elif addr.endswith('/on'): - self.banks[i][j].on = value - if i == self.active_bank: - self.midi_controller.set_channel_mute(j, value) - elif self.banks[i][j].sends != None and addr.endswith('/level'): - bus = int(addr[-8:-6]) - 1 - self.banks[i][j].sends[bus] = value - if i == self.active_bank: - self.midi_controller.set_bus_send(bus, j, value) - break - else: - continue - break - + """Process an OSC input.""" + prefix = None + three_element = '/'.join(addr.split('/',4)[:-1]) # first three parts of the OSC path + two_element = '/'.join(addr.split('/',3)[:-1]) # first two parts of the OSC path + if self.debug: + print('processing OSC message with %s and %s value.' % (addr, value)) + if three_element in self.channels.keys(): + prefix = three_element + elif two_element in self.channels.keys(): + prefix = two_element + elif addr in self.channels.keys(): + prefix = addr + if prefix is not None: + if addr.startswith('/config/mute'): + invert = 1 if value == 0 else 0 + if self.debug: + print (" received mute channel %s" % prefix) + self.channels[prefix].set_mute(0, invert) + number = self.layers[self.current_layer].button_number(prefix) + if number != -1: + self.midi_controller.set_channel_mute(number, invert) + elif addr.endswith('/fader'): # chanel fader level + if self.debug: + print(' Channel %s level %f' % (addr, value)) + self.channels[prefix].set_level(0, value) + number = self.layers[self.current_layer].encoder_number(prefix) + if number != -1: + self.midi_controller.set_ring(number, value) + elif addr.endswith('/on'): # channel enable + if self.debug: + print(' %s unMute %d' % (addr, value)) + self.channels[prefix].set_mute(0, value) + number = self.layers[self.current_layer].button_number(prefix) + if number != -1: + self.midi_controller.set_channel_mute(number, value) + elif addr.endswith('/level'): + if self.debug: + print(' %s level %f' % (addr, value)) + bus = int(addr[-8:-6]) + self.channels[prefix].set_level(bus, value) + number = self.layers[self.current_layer].encoder_number(prefix) + if number != -1: + self.midi_controller.set_ring(number, value) + elif addr.endswith('/gain'): + if self.debug: + print(' %s Gain level %f' % (addr, value)) + self.channels[prefix].set_level("gain", value) + number = self.layers[self.current_layer].encoder_number(prefix) + if number != -1: + self.midi_controller.set_ring(number, value) + elif addr.startswith('/fx') and (addr.endswith('/par/01') or addr.endswith('/par/02')): + if self.fx_slots[int(addr[4:5]) - 1] in self._DELAY_FX_IDS: + self.tempo_detector.current_tempo = value * 3 + elif addr.startswith('/fx/') and addr.endswith('/type'): + self.fx_slots[int(addr[4:5]) - 1] = value + if value in self._DELAY_FX_IDS: + # slot contains a delay, get current time value + param_id = '01' + if value == 10: + param_id = '02' + self.xair_client.send(address = '/fx/%s/par/%s' % (addr[4:5], param_id)) + elif self.debug: + print('processing unknown OSC message with %s and %s value.' % (addr, value)) + def read_initial_state(self): - # Refresh state for all faders and mutes - for i in range(0, 5): - for j in range(0, 8): - if self.banks[i][j] != None: - self.xair_client.send(address = self.banks[i][j].osc_base_addr + '/fader') - time.sleep(0.01) - self.xair_client.send(address = self.banks[i][j].osc_base_addr + '/on') - time.sleep(0.01) - if self.banks[i][j].sends != None: - for k in range(len(self.banks[i][j].sends)): - self.xair_client.send(address = self.banks[i][j].osc_base_addr + '/{:0>2d}/level'.format(k + 1)) - time.sleep(0.01) - - # get all mute groups - for i in range(0, 4): - self.xair_client.send(address = self.mute_groups[i].osc_base_addr) - time.sleep(0.01) - + """ Refresh state for all faders and mutes.""" + for channel in self.channels.values(): + addr = channel.osc_base_addr + if addr.startswith('/head'): + self.xair_client.send(address=addr + '/gain') + time.sleep(self.xair_client._WAIT_TIME) + elif addr.startswith('/config'): + self.xair_client.send(address=addr) + time.sleep(self.xair_client._WAIT_TIME) + else: + self.xair_client.send(address=addr + '/fader') + time.sleep(self.xair_client._WAIT_TIME) + self.xair_client.send(address=addr + '/on') + time.sleep(self.xair_client._WAIT_TIME) + if channel.sends is not None: + for k in range(len(channel.sends)): + self.xair_client.send(address=addr + + '/{:0>2d}/level'.format(k + 1)) + time.sleep(self.xair_client._WAIT_TIME) # get all fx types for i in range(1, 5): self.xair_client.send(address = '/fx/%d/type' % i) - time.sleep(0.01) + time.sleep(self.xair_client._WAIT_TIME) def update_tempo(self, tempo): for i in range(0, 4): @@ -222,4 +557,48 @@ def update_tempo(self, tempo): # only delay where time is set as parameter 02 param_id = '02' self.xair_client.send(address = '/fx/%d/par/%s' % (i + 1, param_id), param = tempo / 3) - \ No newline at end of file + +# the meter subscription is setup in the xair_client in the refresh method that runs every 5s +# a subscription sends values every 50ms for 10s +# +# meters 1 provide data per channel and bus +# meters 2, input levels, as these will match the headamps even if mapped to other channels +# layout below assumes meters 2 + + def received_meters(self, addr, data): + "receive an OSC Meters packet" + data_size = struct.unpack(" 15: + break + # get the current meter as a 16bit signed int mapped to -128db to 128db + # 1/256 db resolution, aka .004 dB + # realistic values max at 0db + value = struct.unpack(" -3 and (i < 8 or i > 11): + # if clip protection is enabled and not a drum and above -3 db + active_bank = self.active_bank + fader = i + if fader < 8: + self.active_bank = 2 + else: + self.active_bank = 3 + fader = fader - 8 + self.change_level(fader, -1) ## needs FIXME + if self.debug: + print("Clipping Detected headamp changed to %s" % + self.banks[self.active_bank][fader].fader) + self.active_bank = active_bank + if self.debug: + print('Meters %s ch 8 %s %s %s' % (addr, values[7], short[7], med[7])) +# if self.screen_obj is not None: +# self.screen_obj.screen_loop() diff --git a/lib/xair.py b/lib/xair.py index 7ff9b35..c3f3864 100644 --- a/lib/xair.py +++ b/lib/xair.py @@ -1,19 +1,27 @@ +"This modules managed communications with the XAir mixer" +# part of xair-remote.py +# Copyright (c) 2018, 2021 Peter Dikant +# Additions Copyright (c) 2021 Ross Dickson +# Some rights reserved. See LICENSE. + import time import threading import socket +import netifaces from pythonosc.dispatcher import Dispatcher from pythonosc.osc_server import BlockingOSCUDPServer from pythonosc.osc_message import OscMessage from pythonosc.osc_message_builder import OscMessageBuilder -from .mixerstate import MixerState class OSCClientServer(BlockingOSCUDPServer): + "The OSC communications agent" def __init__(self, address, dispatcher): super().__init__(('', 0), dispatcher) self.xr_address = address def send_message(self, address, value): - builder = OscMessageBuilder(address = address) + "Packs a message for sending via OSC over UDB." + builder = OscMessageBuilder(address=address) if value is None: values = [] elif isinstance(value, list): @@ -30,66 +38,120 @@ class XAirClient: Handles the communication with the X-Air mixer via the OSC protocol """ _CONNECT_TIMEOUT = 0.5 - _WAIT_TIME = 0.02 + _WAIT_TIME = 0.002 _REFRESH_TIMEOUT = 5 XAIR_PORT = 10024 info_response = [] - + def __init__(self, address, state): self.state = state dispatcher = Dispatcher() dispatcher.set_default_handler(self.msg_handler) self.server = OSCClientServer((address, self.XAIR_PORT), dispatcher) - worker = threading.Thread(target = self.run_server) + worker = threading.Thread(target=self.run_server) worker.daemon = True worker.start() - + def validate_connection(self): + "Confirm that the connection to the XAir is live, otherwise initiaties shutdown." self.send('/xinfo') time.sleep(self._CONNECT_TIMEOUT) if len(self.info_response) > 0: - print('Successfully connected to %s with firmware %s at %s.' % (self.info_response[2], + print('Successfully connected to %s with firmware %s at %s.' % (self.info_response[2], self.info_response[3], self.info_response[0])) else: - print('Error: Failed to setup OSC connection to mixer. Please check for correct ip address.') - exit() - + print('Error: Failed to setup OSC connection to mixer.', + 'Please check for correct ip address.') + self.state.quit_called = True + if self.server is not None: + self.server.shutdown() + self.server = None + def run_server(self): + "Start the OSC communications agent in a seperate thread." try: self.server.serve_forever() except KeyboardInterrupt: + self.quit() + + def stop_server(self): + if self.server is not None: self.server.shutdown() - exit() - + self.server = None + + def quit(self): + if self.state is not None: + self.state.shutdown() + else: + self.stop_server() + def msg_handler(self, addr, *data): - #print 'OSCReceived("%s", %s, %s)' % (addr, tags, data) - if addr.endswith('/fader') or addr.endswith('/on') or addr.endswith('/level') or addr.startswith('/config/mute') or addr.startswith('/fx/'): - self.state.received_osc(addr, data[0]) - elif addr == '/xinfo': - self.info_response = data[:] - - def refresh_connection(self): - # Tells mixer to send changes in state that have not been recieved from this OSC Client - # /xremote - all parameter changes are broadcast to all active clients (Max 4) - # /xremotefnb - No Feed Back. Parameter changes are only sent to the active clients which didn't initiate the change + "Dispatch received OSC messages based on message type." + if self.state is None or self.state.quit_called: + self.stop_server() + return + #print 'OSCReceived("%s", %s, %s)' % (addr, tags, data) + if addr.endswith('/fader') or addr.endswith('/on') or addr.endswith('/level') or \ + addr.startswith('/config/mute') or addr.endswith('/gain') or addr.startswith('/fx/'): + self.state.received_osc(addr, data[0]) + elif addr == '/xinfo': + self.info_response = data[:] + elif addr.startswith('/meters'): + self.state.received_meters(addr, data) + elif addr.startswith('/-'): + pass + else: #if self.state.debug and addr.start: + print('OSCReceived("%s", %s)' % (addr, data)) + + def refresh_connection(self): # the main loop + """ + Tells mixer to send changes in state that have not been received from this OSC Client + /xremote - all parameter changes are broadcast to all active clients (Max 4) + /xremotefnb - No Feed Back. Parameter changes are only sent to the active clients + which didn't initiate the change + """ + if self.state.debug: + print("Refresh Connection %s" % self.state.levels) try: - while True: + while not self.state.quit_called and self.server is not None: self.server.send_message("/xremotenfb", None) + if self.state.levels or self.state.clip: + # using input levels, as these match the headamps when channels are remapped + time.sleep(self._REFRESH_TIMEOUT) + self.send(address="/meters", param=["/meters/2"]) + if self.state.clip: # seems to crash if clipping protection runs for more than one cycle + if self.state.debug: + print("start auto level") + self.state.clip = False +# if self.state.screen_obj is not None: +# self.state.screen_obj.gpio_button[1].disable[0] = 1 time.sleep(self._REFRESH_TIMEOUT) + if self.state.quit_called: + self.quit() + return except KeyboardInterrupt: - exit() - - def send(self, address, param = None): + self.quit() + except socket.error: + self.quit() + + def send(self, address, param=None): + "Call the OSC agent to send a message" self.server.send_message(address, param) - + def find_mixer(): + "Search for the IP address of the XAir mixer" print('Searching for mixer...') client = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) client.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, True) - client.settimeout(15) - client.sendto("/xinfo\0\0".encode(), ("", XAirClient.XAIR_PORT)) + client.settimeout(5) + for iface in netifaces.interfaces(): + try: + bcast = netifaces.ifaddresses(iface)[netifaces.AF_INET][0]['broadcast'] + client.sendto("/xinfo\0\0".encode(), (bcast, XAirClient.XAIR_PORT)) + except: + pass try: response = OscMessage(client.recv(512)) except socket.timeout: diff --git a/peterdikant.json b/peterdikant.json new file mode 100644 index 0000000..c27a641 --- /dev/null +++ b/peterdikant.json @@ -0,0 +1,282 @@ +{ + "LA0": { + "encoders": [ + ["/ch/01/mix", ["mute", "/config/mute/1", "0"]], + ["/ch/02/mix", ["mute", "/config/mute/2", "0"]], + ["/ch/03/mix", ["mute", "/config/mute/3", "0"]], + ["/ch/04/mix", ["mute", "/config/mute/4", "0"]], + ["/ch/05/mix", ["none"]], + ["/ch/06/mix", ["none"]], + ["/ch/07/mix", ["none"]], + ["/ch/08/mix", ["subprocess", "mpc", ["play", "pause"]]] + ], + "buttons": [ + ["mute", "/ch/01/mix"], + ["mute", "/ch/02/mix"], + ["mute", "/ch/03/mix"], + ["mute", "/ch/04/mix"], + ["mute", "/ch/05/mix"], + ["mute", "/ch/06/mix"], + ["mute", "/ch/07/mix"], + ["mute", "/ch/08/mix"], + ["mute", "/config/mute/4"], + ["tap", "tap"], + ["none", "Off"], + ["layer", "LA0", "0", "On"], + ["layer", "LA1", "0", "Off"], + ["layer", "LA2", "0", "Off"], + ["layer", "LA3", "0", "Off"], + ["layer", "LA4", "0", "Off"], + ["layer", "LA0", "0", "On"], + ["layer", "LB0", "10", "Off"] + ], + "fader": [ + ["/lr/mix", "0"] + ] + }, + "LA1": { + "encoders": [ + ["/ch/09/mix", ["mute", "/config/mute/1", "0"]], + ["/ch/10/mix", ["mute", "/config/mute/2", "0"]], + ["/ch/11/mix", ["mute", "/config/mute/3", "0"]], + ["/ch/12/mix", ["mute", "/config/mute/4", "0"]], + ["/ch/13/mix", ["none"]], + ["/ch/14/mix", ["none"]], + ["/ch/15/mix", ["none"]], + ["/ch/16/mix", [["subprocess", "mpc", ["play", "pause"]]]] + ], + "buttons": [ + ["mute", "/ch/09/mix"], + ["mute", "/ch/10/mix"], + ["mute", "/ch/11/mix"], + ["mute", "/ch/12/mix"], + ["mute", "/ch/13/mix"], + ["mute", "/ch/14/mix"], + ["mute", "/ch/15/mix"], + ["mute", "/ch/16/mix"], + ["mute", "/config/mute/4"], + ["tap", "tap"], + ["none", "Off"], + ["layer", "LA0", "0", "Off"], + ["layer", "LA1", "0", "On"], + ["layer", "LA2", "0", "Off"], + ["layer", "LA3", "0", "Off"], + ["layer", "LA4", "0", "Off"], + ["layer", "LA1", "0", "On"], + ["layer", "LB1", "10", "Off"] + ], + "fader": [ + ["/lr/mix", "0"] + ] + }, + "LA2": { + "encoders": [ + ["/rtn/aux/mix", ["mute", "/config/mute/1", "0"]], + ["none", ["mute", "/config/mute/2", "0"]], + ["none", ["mute", "/config/mute/3", "0"]], + ["none", ["mute", "/config/mute/4", "0"]], + ["/dca/1", ["none"]], + ["/dca/2", ["none"]], + ["/dca/3", ["none"]], + ["/dca/4", [["subprocess", "mpc", ["play", "pause"]]]] + ], + "buttons": [ + ["mute", "/rtn/aux/mix"], + ["none", "Off"], + ["none", "Off"], + ["none", "Off"], + ["mute", "/dca/1"], + ["mute", "/dca/2"], + ["mute", "/dca/3"], + ["mute", "/dca/4"], + ["mute", "/config/mute/4"], + ["tap", "tap"], + ["none", "Off"], + ["layer", "LA0", "0", "Off"], + ["layer", "LA1", "0", "Off"], + ["layer", "LA2", "0", "On"], + ["layer", "LA3", "0", "Off"], + ["layer", "LA4", "0", "Off"], + ["layer", "LA2", "0", "On"], + ["layer", "LB2", "10", "Off"] + ], + "fader": [ + ["/lr/mix", "0"] + ] + }, + "LA3": { + "encoders": [ + ["/fxsend/1/mix", ["mute", "/config/mute/1", "0"]], + ["/fxsend/2/mix", ["mute", "/config/mute/2", "0"]], + ["/fxsend/3/mix", ["mute", "/config/mute/3", "0"]], + ["/fxsend/4/mix", ["mute", "/config/mute/4", "0"]], + ["/rtn/1/mix", ["none"]], + ["/rtn/2/mix", ["none"]], + ["/rtn/3/mix", ["none"]], + ["/rtn/4/mix", [["subprocess", "mpc", ["play", "pause"]]]] + ], + "buttons": [ + ["mute", "/fxsend/1/mix"], + ["mute", "/fxsend/2/mix"], + ["mute", "/fxsend/3/mix"], + ["mute", "/fxsend/4/mix"], + ["mute", "/rtn/1/mix"], + ["mute", "/rtn/2/mix"], + ["mute", "/rtn/3/mix"], + ["mute", "/rtn/4/mix"], + ["mute", "/config/mute/4"], + ["tap", "tap"], + ["none", "Off"], + ["layer", "LA0", "0", "Off"], + ["layer", "LA1", "0", "Off"], + ["layer", "LA2", "0", "Off"], + ["layer", "LA3", "0", "On"], + ["layer", "LA4", "0", "Off"], + ["layer", "LA3", "0", "On"], + ["layer", "LB2", "10", "Off"] + ], + "fader": [ + ["/lr/mix", "0"] + ] + }, + "LA4": { + "encoders": [ + ["/bus/1/mix", ["mute", "/config/mute/1", "0"]], + ["/bus/2/mix", ["mute", "/config/mute/2", "0"]], + ["/bus/3/mix", ["mute", "/config/mute/3", "0"]], + ["/bus/4/mix", ["mute", "/config/mute/4", "0"]], + ["/bus/5/mix", ["none"]], + ["/bus/6/mix", ["none"]], + ["none", [""]], + ["none", [["subprocess", "mpc", ["play", "pause"]]]] + ], + "buttons": [ + ["mute", "/bus/1/mix"], + ["mute", "/bus/2/mix"], + ["mute", "/bus/3/mix"], + ["mute", "/bus/4/mix"], + ["mute", "/bus/5/mix"], + ["mute", "/bus/6/mix"], + ["none", "Off"], + ["none", "Off"], + ["mute", "/config/mute/4"], + ["tap", "tap"], + ["none", "Off"], + ["layer", "LA0", "0", "Off"], + ["layer", "LA1", "0", "Off"], + ["layer", "LA2", "0", "Off"], + ["layer", "LA3", "0", "Off"], + ["layer", "LA4", "0", "On"], + ["layer", "LA4", "0", "On"], + ["layer", "LB2", "10", "Off"] + ], + "fader": [ + ["/lr/mix", "0"] + ] + }, + "LB0": { + "encoders": [ + ["/ch/01/mix", ["mute", "/config/mute/1", "0"]], + ["/ch/02/mix", ["mute", "/config/mute/2", "0"]], + ["/ch/03/mix", ["mute", "/config/mute/3", "0"]], + ["/ch/04/mix", ["mute", "/config/mute/4", "0"]], + ["/ch/05/mix", ["none"]], + ["/ch/06/mix", ["none"]], + ["/ch/07/mix", ["none"]], + ["/ch/08/mix", [["subprocess", "mpc", ["play", "pause"]]]] + ], + "buttons": [ + ["send", "1"], + ["send", "2"], + ["send", "3"], + ["send", "4"], + ["send", "5"], + ["send", "6"], + ["send", "7"], + ["send", "8"], + ["mute", "/config/mute/4"], + ["tap", "tap"], + ["none", "Off"], + ["layer", "LB0", "10", "On"], + ["layer", "LB1", "10", "Off"], + ["layer", "LB2", "10", "Off"], + ["send", "9"], + ["send", "10"], + ["layer", "LA0", "0", "Off"], + ["layer", "LB0", "10", "On"] + ], + "fader": [ + ["/lr/mix", "0"] + ] + }, + "LB1": { + "encoders": [ + ["/ch/09/mix", ["mute", "/config/mute/1", "0"]], + ["/ch/10/mix", ["mute", "/config/mute/2", "0"]], + ["/ch/11/mix", ["mute", "/config/mute/3", "0"]], + ["/ch/12/mix", ["mute", "/config/mute/4", "0"]], + ["/ch/13/mix", ["none"]], + ["/ch/14/mix", ["none"]], + ["/ch/15/mix", ["none"]], + ["/ch/16/mix", [["subprocess", "mpc", ["play", "pause"]]]] + ], + "buttons": [ + ["send", "1"], + ["send", "2"], + ["send", "3"], + ["send", "4"], + ["send", "5"], + ["send", "6"], + ["send", "7"], + ["send", "8"], + ["mute", "/config/mute/4"], + ["tap", "tap"], + ["none", "Off"], + ["layer", "LB0", "10", "Off"], + ["layer", "LB1", "10", "On"], + ["layer", "LB2", "10", "Off"], + ["send", "9"], + ["send", "10"], + ["layer", "LA1", "0", "Off"], + ["layer", "LB1", "10", "On"] + ], + "fader": [ + ["/lr/mix", "0"] + ] + }, + "LB2": { + "encoders": [ + ["/rtn/aux/mix", ["mute", "/config/mute/1", "0"]], + ["none", ["mute", "/config/mute/2", "0"]], + ["none", ["mute", "/config/mute/3", "0"]], + ["none", ["mute", "/config/mute/4", "0"]], + ["/dca/1", ["none"]], + ["/dca/2", ["none"]], + ["/dca/3", ["none"]], + ["/dca/4", [["subprocess", "mpc", ["play", "pause"]]]] + ], + "buttons": [ + ["send", "1"], + ["send", "2"], + ["send", "3"], + ["send", "4"], + ["send", "5"], + ["send", "6"], + ["send", "7"], + ["send", "8"], + ["mute", "/config/mute/4"], + ["tap", "tap"], + ["none", "Off"], + ["layer", "LB0", "10", "Off"], + ["layer", "LB1", "10", "Off"], + ["layer", "LB2", "10", "On"], + ["send", "9"], + ["send", "10"], + ["layer", "LA2", "0", "Off"], + ["layer", "LB2", "10", "On"] + ], + "fader": [ + ["/lr/mix", "0"] + ] + } +} diff --git a/requirements.txt b/requirements.txt index 4a16393..3870e78 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ mido python-rtmidi python-osc +netifaces diff --git a/rossdickson.json b/rossdickson.json new file mode 100644 index 0000000..8cc3484 --- /dev/null +++ b/rossdickson.json @@ -0,0 +1,177 @@ +{ + "A": { + "encoders": [ + ["/ch/01/mix", ["reset", "0.750000"]], + ["/ch/02/mix", ["reset", "0.750000"]], + ["/ch/03/mix", ["reset", "0.750000"]], + ["/ch/04/mix", ["reset", "0.750000"]], + ["/ch/05/mix", ["reset", "0.750000"]], + ["/ch/06/mix", ["reset", "0.750000"]], + ["/ch/07/mix", ["reset", "0.750000"]], + ["/ch/08/mix", ["reset", "0.750000"]] + ], + "buttons": [ + ["mute", "/ch/01/mix"], + ["mute", "/ch/02/mix"], + ["mute", "/ch/03/mix"], + ["mute", "/ch/04/mix"], + ["mute", "/ch/05/mix"], + ["mute", "/ch/06/mix"], + ["mute", "/ch/07/mix"], + ["mute", "/ch/08/mix"], + ["send", "1"], + ["send", "2"], + ["send", "3"], + ["send", "4"], + ["send", "5"], + ["send", "6"], + ["send", "7"], + ["layer", "Gain", "0", "Off"], + ["layer", "Aprime", "7", "On"], + ["layer", "B", "0", "Off"] + ], + "fader": [ + ["quit", "0"] + ] + }, + "Aprime": { + "encoders": [ + ["/ch/09/mix", ["reset", "0.750000"]], + ["/ch/10/mix", ["reset", "0.750000"]], + ["/ch/11/mix", ["reset", "0.750000"]], + ["/ch/12/mix", ["reset", "0.750000"]], + ["/ch/13/mix", ["reset", "0.750000"]], + ["/ch/14/mix", ["reset", "0.750000"]], + ["/ch/15/mix", ["reset", "0.750000"]], + ["/ch/16/mix", ["reset", "0.750000"]] + ], + "buttons": [ + ["mute", "/ch/09/mix"], + ["mute", "/ch/10/mix"], + ["mute", "/ch/11/mix"], + ["mute", "/ch/12/mix"], + ["mute", "/ch/13/mix"], + ["mute", "/ch/14/mix"], + ["mute", "/ch/15/mix"], + ["mute", "/ch/16/mix"], + ["send", "1"], + ["send", "2"], + ["send", "3"], + ["send", "4"], + ["send", "5"], + ["send", "6"], + ["send", "7"], + ["layer", "Gain", "0", "Off"], + ["layer", "A", "7", "Blink"], + ["layer", "B", "0", "Off"] + ], + "fader": [ + ["quit", "0"] + ] + }, + "B": { + "encoders": [ + ["/bus/1/mix", ["reset", "0.375367"]], + ["/bus/2/mix", ["reset", "0.375367"]], + ["/bus/3/mix", ["reset", "0.375367"]], + ["/bus/4/mix", ["reset", "0.375367"]], + ["/bus/5/mix", ["reset", "0.375367"]], + ["/bus/6/mix", ["reset", "0.375367"]], + ["/rtn/aux/mix", ["reset", "0.750000"]], + ["/lr/mix", ["reset", "0.375367"]] + ], + "buttons": [ + ["mute", "/bus/1/mix"], + ["mute", "/bus/2/mix"], + ["mute", "/bus/3/mix"], + ["mute", "/bus/4/mix"], + ["mute", "/bus/5/mix"], + ["mute", "/bus/6/mix"], + ["mute", "/rtn/aux/mix"], + ["mute", "/lr/mix"], + ["none", "1"], + ["none", "2"], + ["none", "3"], + ["none", "4"], + ["none", "5"], + ["none", "6"], + ["none", "7"], + ["layer", "Gain", "0", "Off"], + ["layer", "A", "7", "Off"], + ["layer", "B", "0", "On"] + ], + "fader": [ + ["quit", "0"] + ] + }, + "Gain": { + "encoders": [ + ["/headamp/01", ["none"]], + ["/headamp/02", ["none"]], + ["/headamp/03", ["none"]], + ["/headamp/04", ["none"]], + ["/headamp/05", ["none"]], + ["/headamp/06", ["none"]], + ["/headamp/07", ["none"]], + ["/headamp/08", ["none"]] + ], + "buttons": [ + ["none", "Off"], + ["none", "Off"], + ["quit", "State"], + ["quit", "State"], + ["none", "Off"], + ["none", "Off"], + ["clip", "State"], + ["none", "State"], + ["none", "1"], + ["none", "2"], + ["none", "3"], + ["none", "4"], + ["none", "5"], + ["none", "6"], + ["none", "7"], + ["layer", "Gainprime", "0", "On"], + ["layer", "A", "0", "Off"], + ["layer", "B", "0", "Off"] + ], + "fader": [ + ["quit", "0"] + ] + }, + "Gainprime": { + "encoders": [ + ["/headamp/09", ["none"]], + ["/headamp/10", ["none"]], + ["/headamp/11", ["none"]], + ["/headamp/12", ["none"]], + ["/headamp/13", ["none"]], + ["/headamp/14", ["none"]], + ["/headamp/15", ["none"]], + ["/headamp/16", ["none"]] + ], + "buttons": [ + ["none", "Off"], + ["none", "Off"], + ["quit", "State"], + ["quit", "State"], + ["none", "Off"], + ["none", "Off"], + ["clip", "State"], + ["none", "State"], + ["none", "1"], + ["none", "2"], + ["none", "3"], + ["none", "4"], + ["none", "5"], + ["none", "6"], + ["none", "7"], + ["layer", "Gain", "0", "Blink"], + ["layer", "A", "0", "Off"], + ["layer", "B", "0", "Off"] + ], + "fader": [ + ["quit", "0"] + ] + } +} diff --git a/simple.json b/simple.json new file mode 100644 index 0000000..31f0757 --- /dev/null +++ b/simple.json @@ -0,0 +1,72 @@ +{ + "A": { + "encoders": [ + ["/ch/01/mix", ["reset", "0.750000"]], + ["/ch/02/mix", ["reset", "0.750000"]], + ["/ch/03/mix", ["reset", "0.750000"]], + ["/ch/04/mix", ["reset", "0.750000"]], + ["/ch/05/mix", ["reset", "0.750000"]], + ["/ch/06/mix", ["reset", "0.750000"]], + ["/ch/07/mix", ["reset", "0.750000"]], + ["/ch/08/mix", ["reset", "0.750000"]] + ], + "buttons": [ + ["mute", "/ch/01/mix"], + ["mute", "/ch/02/mix"], + ["mute", "/ch/03/mix"], + ["mute", "/ch/04/mix"], + ["mute", "/ch/05/mix"], + ["mute", "/ch/06/mix"], + ["mute", "/ch/07/mix"], + ["mute", "/ch/08/mix"], + ["mute", "/ch/09/mix"], + ["mute", "/ch/10/mix"], + ["mute", "/ch/11/mix"], + ["mute", "/ch/12/mix"], + ["mute", "/ch/13/mix"], + ["mute", "/ch/14/mix"], + ["mute", "/ch/15/mix"], + ["mute", "/ch/16/mix"], + ["layer", "A", "0", "On"], + ["layer", "B", "0", "Off"] + ], + "fader": [ + ["none", "0"] + ] + }, + "B": { + "encoders": [ + ["/ch/09/mix", ["reset", "0.750000"]], + ["/ch/10/mix", ["reset", "0.750000"]], + ["/ch/11/mix", ["reset", "0.750000"]], + ["/ch/12/mix", ["reset", "0.750000"]], + ["/ch/13/mix", ["reset", "0.750000"]], + ["/ch/14/mix", ["reset", "0.750000"]], + ["/ch/15/mix", ["reset", "0.750000"]], + ["/ch/16/mix", ["reset", "0.750000"]] + ], + "buttons": [ + ["mute", "/ch/01/mix"], + ["mute", "/ch/02/mix"], + ["mute", "/ch/03/mix"], + ["mute", "/ch/04/mix"], + ["mute", "/ch/05/mix"], + ["mute", "/ch/06/mix"], + ["mute", "/ch/07/mix"], + ["mute", "/ch/08/mix"], + ["mute", "/ch/09/mix"], + ["mute", "/ch/10/mix"], + ["mute", "/ch/11/mix"], + ["mute", "/ch/12/mix"], + ["mute", "/ch/13/mix"], + ["mute", "/ch/14/mix"], + ["mute", "/ch/15/mix"], + ["mute", "/ch/16/mix"], + ["layer", "A", "0", "Off"], + ["layer", "B", "0", "On"] + ], + "fader": [ + ["none", "0"] + ] + } +} diff --git a/xair-remote.py b/xair-remote.py index 897a6af..66077af 100755 --- a/xair-remote.py +++ b/xair-remote.py @@ -1,38 +1,46 @@ +"Starts an xAir remote, see main help string for more" #!/usr/bin/env python3 + +# part of xair-remote.py +# Copyright (c) 2018, 2021 Peter Dikant +# Additions Copyright (c) 2021 Ross Dickson +# Some rights reserved. See LICENSE. + import argparse -import threading -from lib.midicontroller import MidiController -from lib.xair import XAirClient, find_mixer from lib.mixerstate import MixerState if __name__ == '__main__': - parser = argparse.ArgumentParser(description = 'Remote control X-Air mixers with a midi controller') - parser.add_argument('xair_address', help = 'ip address of your X-Air mixer (optional)', nargs = '?') - parser.add_argument('-m', '--monitor', help='monitor X-Touch connection and exit when disconnected', action="store_true") - args = parser.parse_args() - - if args.xair_address is None: - address = find_mixer() - if address is None: - print('Error: Could not find any mixers in network. Please specify ip address manually.') - exit() - else: - args.xair_address = address + PARSER = argparse.ArgumentParser(description=""" + Remote control X-Air mixers with an X-Touch midi controller. - state = MixerState() - midi = MidiController(state) - state.midi_controller = midi - xair = XAirClient(args.xair_address, state) - state.xair_client = xair - xair.validate_connection() + The X-Touch is setup so that the encoders are configured as faders on any of + 10 banks with the first row of button configured as mutes for the selected + bank. Banks are selected with the buttons in the lower row and the layer + buttons. Most banks have two layers, the second access by pressing the + button a second time, causing the button to blink. The fader is used as a + master exit control. Pressing an encoder returns the level to unity gain, + not used for mic pre. + """, + epilog=""" + Bank 1-6 - Aux Bus 1-6 levels for Channels 1-8 and 9-16. + Bank 7 - Aux Bus 7 aka FX 1 for Channels 1-8 and 9-16. + Bank 8 - Mic PreAmp levels for Channels 1-8 and 9-16. + Layer A - Main LR Mix levels of Channels 1-8 and 9-16 on second layer. + Layer B - Aux Bus 1-6 output levels, USB IN Gain, Main/LR Bus output level. + """, + formatter_class=argparse.RawDescriptionHelpFormatter) + PARSER.add_argument('xair_address', help='ip address of your X-Air mixer (optional)', nargs='?') + PARSER.add_argument('-m', '--monitor', + help='monitor X-Touch connection and exit when disconnected', + action="store_true") + PARSER.add_argument('-d', '--debug', help='enable debug output', action="store_true") + PARSER.add_argument('-l', '--levels', help='get levels from the mixer', action="store_true") + PARSER.add_argument('-c', '--clip', help='enabling auto leveling to avoid clipping', + action="store_true") + PARSER.add_argument('-f', '--config_file', help="JSON formated config file", nargs=1) + ARGS = PARSER.parse_args() - if args.monitor: - print('Monitoring X-Touch connection enabled') - monitor = threading.Thread(target = midi.monitor_ports) - monitor.daemon = True - monitor.start() - - state.read_initial_state() - - # now refresh /xremote command while running - xair.refresh_connection() + STATE = MixerState(ARGS) + if STATE.initialize_state(): + # now start polling refresh /xremote command while running + STATE.xair_client.refresh_connection()