From bf9b72f31f8c2be6dc1f77eabeb030e78cd39ad3 Mon Sep 17 00:00:00 2001 From: onyx-and-iris <75868496+onyx-and-iris@users.noreply.github.com> Date: Tue, 5 Apr 2022 20:05:55 +0100 Subject: [PATCH] initial commit initial commit --- .gitignore | 53 ++++ LICENSE | 22 ++ README.md | 262 ++++++++++++++++ __main__.py | 14 + mair/__init__.py | 3 + mair/bus.py | 72 +++++ mair/config.py | 210 +++++++++++++ mair/dca.py | 59 ++++ mair/errors.py | 4 + mair/fx.py | 82 +++++ mair/kinds.py | 19 ++ mair/lr.py | 70 +++++ mair/mair.py | 138 +++++++++ mair/meta.py | 77 +++++ mair/rtn.py | 109 +++++++ mair/shared.py | 680 ++++++++++++++++++++++++++++++++++++++++++ mair/strip.py | 74 +++++ mair/util.py | 77 +++++ setup.py | 18 ++ tests/__init__.py | 17 ++ tests/shared_tests.py | 332 +++++++++++++++++++++ 21 files changed, 2392 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 __main__.py create mode 100644 mair/__init__.py create mode 100644 mair/bus.py create mode 100644 mair/config.py create mode 100644 mair/dca.py create mode 100644 mair/errors.py create mode 100644 mair/fx.py create mode 100644 mair/kinds.py create mode 100644 mair/lr.py create mode 100644 mair/mair.py create mode 100644 mair/meta.py create mode 100644 mair/rtn.py create mode 100644 mair/shared.py create mode 100644 mair/strip.py create mode 100644 mair/util.py create mode 100644 setup.py create mode 100644 tests/__init__.py create mode 100644 tests/shared_tests.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8d6af4b --- /dev/null +++ b/.gitignore @@ -0,0 +1,53 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] + +# C extensions +*.so + +# Distribution / packaging +bin/ +build/ +develop-eggs/ +dist/ +eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +.tox/ +.coverage +.cache +nosetests.xml +coverage.xml + +# Translations +*.mo + +# Mr Developer +.mr.developer.cfg +.project +.pydevproject + +# Rope +.ropeproject + +# Django stuff: +*.log +*.pot + +# Sphinx documentation +docs/_build/ + +# config files +*.ini diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2a8991b --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2018 Peter Dikant +Copyright (c) 2022 Onyx and Iris + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a16ade0 --- /dev/null +++ b/README.md @@ -0,0 +1,262 @@ +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/onyx-and-iris/xair-api-python/blob/dev/LICENSE) +[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) +# Mair Remote + +This package offers a python interface to the Midas MR18 digital rack mixer. +All testing was done using a Midas MR18 but I've been informed that the software for XR18 is identical. +Midas are not affiliated with/nor do they support this package in any way. + +## Prerequisites +- Python 3.9+ + +## Installation +``` +git clone https://github.com/onyx-and-iris/mair-api-python +cd mair-api-python +``` + +Just the interface: +``` +pip install . +``` + +With development dependencies: +``` +pip install -e .['development'] +``` + +## Usage +### Connection +An ini file named config.ini, placed into the current working directory of your code may be used to configure the mixers ip. It's contents should resemble: +``` +[connection] +ip= +``` +Alternatively you may state it explicitly as an argument to mair.connect() + +### Example 1 +```python +import mair + +def main(): + with mair.connect(kind_id, ip=ip) as mixer: + mixer.strip[8].config.name = 'sm7b' + mixer.strip[8].config.on = True + print(f'strip 09 ({mixer.strip[8].config.name}) has been set to {mixer.strip[8].config.on}') + +if __name__ == '__main__': + kind_id = 'MR18' + ip = '' + + main() +``` + +## API +Currently the following devices are support: +- `XR18` +- `MR18` + +However, this interface can be expanded upon to support other devices. + +### MAirRemote (higher level) +`mixer.lr` + +A class representing Main LR channel + +`mixer.strip` + +A Strip tuple containing a class for each input strip channel + +`mixer.bus` + +A Bus tuple containing a class for each output bus channel + +`mixer.dca` + +A DCA tuple containing a class for each DCA group + +`mixer.fxsend` + +An FXSend tuple containing a class for each FX Send channel + +`mixer.fxreturn` + +An FXReturn tuple containing a class for each FX Return channel + +`mixer.aux` + +A class representing aux channel + +`mixer.rtn` + +An RTN tuple containing a class for each rtn channel + +`mixer.config` + +A class representing the main config settings + + +### `LR` +Contains the subclasses: +(`Config`, `Dyn`, `Insert`, `EQ`, `Mix`) + +### `Strip` +Contains the subclasses: +(`Config`, `Preamp`, `Gate`, `Dyn`, `Insert`, `GEQ`, `EQ`, `Mix`, `Group`, `Automix`) + +### `Bus` +Contains the subclasses: +(`Config`, `Dyn`, `Insert`, `EQ`, `Mix`, `Group`) + +### `FXSend` +Contains the subclasses: +(`Config`, `Mix`, `Group`) + +### `Aux` +Contains the subclasses: +(`Config`, `Preamp`, `EQ`, `Mix`, `Group`) + +### `Rtn` +Contains the subclasses: +(`Config`, `Preamp`, `EQ`, `Mix`, `Group`) + + +### `Subclasses` +For each subclass the corresponding properties are available. + +`Config` +- `name`: string +- `color`: int, from 0, 16 +- `inputsource`: int +- `usbreturn`: int + +`Preamp` +- `on`: bool +- `usbtrim`: float, from -18.0 to 18.0 +- `usbinput`: bool +- `invert`: bool +- `highpasson`: bool +- `highpassfilter`: int, from 20 to 400 + +`Gate` +- `on`: bool +- `mode`: str, one of ('gate', 'exp2', 'exp3', 'exp4', 'duck') +- `threshold`: float, from -80.0 to 0.0 +- `range`: int, from 3 to 60 +- `attack`: int, from 0 to 120 +- `hold`: float, from 0.02 to 2000 +- `release`: int, from 5 to 4000 +- `keysource`, from 0 to 22 +- `filteron`: bool +- `filtertype`: int, from 0 to 8 +- `filterfreq`: float, from 20 to 20000 + +`Dyn` +- `on`: bool +- `mode`: str, one of ('comp', 'exp') +- `det`: str, one of ('peak', 'rms') +- `env`: str, one of ('lin', 'log') +- `threshold`: float, from -60.0 to 0.0 +- `ratio`: int, from 0 to 11 +- `knee`: int, from 0 to 5 +- `mgain`: float, from 0.0 to 24.0 +- `attack`: int, from 0 to 120 +- `hold`: float, from 0.02 to 2000 +- `release`: int, from 5 to 4000 +- `mix`: int, from 0 to 100 +- `keysource`: int, from 0 to 22 +- `auto`: bool +- `filteron`: bool +- `filtertype`: int, from 0 to 8 +- `filterfreq`: float, from 20 to 20000 + +`Insert` +- `on`: bool +- `sel`: int + +`GEQ` +The following method names preceded by `slider_` +- `20`, `25`, `31_5`, `40`, `50`, `63`, `80`, `100`, `125`, `160`, +- `200`, `250`, `315`, `400`, `500`, `630`, `800`, `1k`, `1k25`, `1k6`, `2k`, +- `2k5`, `3k15`, `4k`, `5k`, `6k3`, `8k`, `10k`, `12k5`, `16k`, `20k`: float, from -15.0 to 15.0 + +for example: `slider_20`, `slider_6k3` etc.. + +`EQ` +- `on`: bool +- `mode`: str, one of ('peq', 'geq', 'teq') + +For the subclasses: `low`, `low2`, `lomid`, `himid`, `high2`, `high` the following properties are available: +- `type`: int, from 0 to 5 +- `frequency`: float, from 20.0 to 20000.0 +- `gain`: float, -15.0 to 15.0 +- `quality`: float, from 0.3 to 10.0 + +for example: `eq.low2.type` + +`Mix` +- `on`: bool +- `fader`: float, -inf, to 10.0 +- `lr`: bool + +`Group` +- `dca`: int, from 0 to 15 +- `mute`: int, from 0 to 15 + +`Automix` +- `group`: int, from 0 to 2 +- `weight`: float, from -12.0 to 12.0 + + +### `DCA` +- `on`: bool +- `name`: str +- `color`: int, from 0 to 15 + + +### `Config` +The following method names preceded by `chlink` +- `1_2`, `3_4`, `5_6`, `7_8`, `9_10`, `11_12`, `13_14`, `15_16` + +The following method names preceded by `buslink` +- `1_2`, `3_4`, `5_6` + +for example: `chlink1_2`, `buslink5_6` etc.. + +- `link_eq`: bool +- `link_dyn`: bool +- `link_fader_mute`: bool +- `amixenable`: bool +- `amixlock`: bool +- `mute_group`: bool + +For the subclass `monitor` the following properties are available +- `level`: float, -inf to 10.0 +- `source`: int, from 0 to 14 +- `chmode` bool +- `busmode` bool +- `dim` bool +- `mono` bool +- `mute` bool +- `dimfpl` bool + +for example: `config.monitor.chmode` + + +### `Tests` +First make sure you installed the [development dependencies](https://github.com/onyx-and-iris/mair-api-python#installation) + +To run the tests from tests directory: + +WARNING: First save your settings and make sure your equipment is safe from damage. +Run tests at your own risk. + +`nosetests --r test -v`. + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details + +## Special Thanks + +Peter Dikant for writing the base class diff --git a/__main__.py b/__main__.py new file mode 100644 index 0000000..566f58c --- /dev/null +++ b/__main__.py @@ -0,0 +1,14 @@ +import mair +from time import sleep + +def main(): + with mair.connect(kind_id, ip=ip) as mixer: + mixer.strip[8].config.name = 'sm7b' + mixer.strip[8].config.on = True + print(f'strip 09 ({mixer.strip[8].config.name}) has been set to {mixer.strip[8].config.on}') + +if __name__ == '__main__': + kind_id = 'MR18' + ip = '' + + main() diff --git a/mair/__init__.py b/mair/__init__.py new file mode 100644 index 0000000..6ab08ef --- /dev/null +++ b/mair/__init__.py @@ -0,0 +1,3 @@ +from .mair import connect + +_ALL__ = ['connect'] diff --git a/mair/bus.py b/mair/bus.py new file mode 100644 index 0000000..3bad44f --- /dev/null +++ b/mair/bus.py @@ -0,0 +1,72 @@ +import abc +from .errors import MAirRemoteError +from .shared import ( + Config, + Preamp, + Gate, + Dyn, + Insert, + EQ, + GEQ, + Mix, + Group, + Automix, +) + + +class IBus(abc.ABC): + """Abstract Base Class for buses""" + + def __init__(self, remote, index: int): + self._remote = remote + self.index = index + 1 + + def getter(self, param: str): + self._remote.send(f"{self.address}/{param}") + return self._remote.info_response + + def setter(self, param: str, val: int): + self._remote.send(f"{self.address}/{param}", val) + + @abc.abstractmethod + def address(self): + pass + + +class Bus(IBus): + """Concrete class for buses""" + + @classmethod + def make(cls, remote, index): + """ + Factory function for buses + + Creates a mixin of shared subclasses, sets them as class attributes. + + Returns a Bus class of a kind. + """ + BUS_cls = type( + f"Bus{remote.kind.id_}", + (cls,), + { + **{ + _cls.__name__.lower(): type( + f"{_cls.__name__}{remote.kind.id_}", (_cls, cls), {} + )(remote, index) + for _cls in ( + Config, + Dyn, + Insert, + GEQ.make(), + EQ.make_sixband(cls, remote, index), + Mix, + Group, + ) + } + }, + ) + return BUS_cls(remote, index) + + @property + def address(self) -> str: + return f"/bus/{self.index}" diff --git a/mair/config.py b/mair/config.py new file mode 100644 index 0000000..d1ca3eb --- /dev/null +++ b/mair/config.py @@ -0,0 +1,210 @@ +import abc +from .errors import MAirRemoteError +from . import kinds +from .meta import bool_prop +from .util import _get_level_val, _set_level_val, lin_get, lin_set + + +class IConfig(abc.ABC): + """Abstract Base Class for config""" + + def __init__(self, remote): + self._remote = remote + + def getter(self, param: str): + self._remote.send(f"{self.address}/{param}") + return self._remote.info_response + + def setter(self, param: str, val: int): + self._remote.send(f"{self.address}/{param}", val) + + @abc.abstractmethod + def address(self): + pass + + +class Config(IConfig): + """Concrete class for config""" + + @classmethod + def make(cls, remote): + """ + Factory function for Config + + Returns a Config class of a kind. + """ + LINKS_cls = _make_links_mixins[remote.kind.id_] + MONITOR_cls = type(f"ConfigMonitor", (Config.Monitor, cls), {}) + CONFIG_cls = type( + f"Config{remote.kind.id_}", + (cls, LINKS_cls), + {"monitor": MONITOR_cls(remote)}, + ) + return CONFIG_cls(remote) + + @property + def address(self) -> str: + return f"/config" + + @property + def amixenable(self) -> bool: + return self.getter("mute")[0] == 1 + + @amixenable.setter + def amixenable(self, val: bool): + if not isinstance(val, bool): + raise MAirRemoteError("amixenable is a bool parameter") + self.setter("amixenable", 1 if val else 0) + + @property + def amixlock(self) -> bool: + return self.getter("amixlock")[0] == 1 + + @amixlock.setter + def amixlock(self, val: bool): + if not isinstance(val, bool): + raise MAirRemoteError("amixlock is a bool parameter") + self.setter("amixlock", 1 if val else 0) + + @property + def mute_group(self) -> bool: + return self.getter("mute")[0] == 1 + + @mute_group.setter + def mute_group(self, val: bool): + if not isinstance(val, bool): + raise MAirRemoteError("mute_group is a bool parameter") + self.setter("mute", 1 if val else 0) + + class Monitor: + @property + def address(self) -> str: + root = super(Config.Monitor, self).address + return f"{root}/solo" + + @property + def level(self) -> float: + retval = self.getter("level")[0] + return _get_level_val(retval) + + @level.setter + def level(self, val: float): + _set_level_val(self, val) + + @property + def source(self) -> int: + return int(self.getter("source")[0]) + + @source.setter + def source(self, val: int): + if not isinstance(val, int): + raise MAirRemoteError("source is an int parameter") + self.setter(f"source", val) + + @property + def sourcetrim(self) -> float: + return round(lin_get(-18, 18, self.getter("sourcetrim")[0]), 1) + + @sourcetrim.setter + def sourcetrim(self, val: float): + if not isinstance(val, float): + raise MAirRemoteError( + "sourcetrim is a float parameter, expected value in range -18 to 18" + ) + self.setter("sourcetrim", lin_set(-18, 18, val)) + + @property + def chmode(self) -> bool: + return self.getter("chmode")[0] == 1 + + @chmode.setter + def chmode(self, val: bool): + if not isinstance(val, bool): + raise MAirRemoteError("chmode is a bool parameter") + self.setter("chmode", 1 if val else 0) + + @property + def busmode(self) -> bool: + return self.getter("busmode")[0] == 1 + + @busmode.setter + def busmode(self, val: bool): + if not isinstance(val, bool): + raise MAirRemoteError("busmode is a bool parameter") + self.setter("busmode", 1 if val else 0) + + @property + def dimgain(self) -> int: + return int(lin_get(-40, 0, self.getter("dimatt")[0])) + + @dimgain.setter + def dimgain(self, val: int): + if not isinstance(val, int): + raise MAirRemoteError( + "dimgain is an int parameter, expected value in range -40 to 0" + ) + self.setter("dimatt", lin_set(-40, 0, val)) + + @property + def dim(self) -> bool: + return self.getter("dim")[0] == 1 + + @dim.setter + def dim(self, val: bool): + if not isinstance(val, bool): + raise MAirRemoteError("dim is a bool parameter") + self.setter("dim", 1 if val else 0) + + @property + def mono(self) -> bool: + return self.getter("mono")[0] == 1 + + @mono.setter + def mono(self, val: bool): + if not isinstance(val, bool): + raise MAirRemoteError("mono is a bool parameter") + self.setter("mono", 1 if val else 0) + + @property + def mute(self) -> bool: + return self.getter("mute")[0] == 1 + + @mute.setter + def mute(self, val: bool): + if not isinstance(val, bool): + raise MAirRemoteError("mute is a bool parameter") + self.setter("mute", 1 if val else 0) + + @property + def dimfpl(self) -> bool: + return self.getter("dimfpl")[0] == 1 + + @dimfpl.setter + def dimfpl(self, val: bool): + if not isinstance(val, bool): + raise MAirRemoteError("dimfpl is a bool parameter") + self.setter("dimfpl", 1 if val else 0) + + +def _make_links_mixin(kind): + """Creates a links mixin""" + return type( + f"Links{kind.id_}", + (), + { + "link_eq": bool_prop("linkcfg/eq"), + "link_dyn": bool_prop("linkcfg/dyn"), + "link_fader_mute": bool_prop("linkcfg/fdrmute"), + **{ + f"chlink{i}_{i+1}": bool_prop(f"chlink/{i}-{i+1}") + for i in range(1, kind.num_strip, 2) + }, + **{ + f"buslink{i}_{i+1}": bool_prop(f"buslink/{i}-{i+1}") + for i in range(1, kind.num_bus, 2) + }, + }, + ) + + +_make_links_mixins = {kind.id_: _make_links_mixin(kind) for kind in kinds.all} diff --git a/mair/dca.py b/mair/dca.py new file mode 100644 index 0000000..8ca2660 --- /dev/null +++ b/mair/dca.py @@ -0,0 +1,59 @@ +import abc +from .errors import MAirRemoteError + + +class IDCA(abc.ABC): + """Abstract Base Class for DCA groups""" + + def __init__(self, remote, index: int): + self._remote = remote + self.index = index + 1 + + def getter(self, param: str) -> tuple: + self._remote.send(f"{self.address}/{param}") + return self._remote.info_response + + def setter(self, param: str, val: int): + self._remote.send(f"{self.address}/{param}", val) + + @abc.abstractmethod + def address(self): + pass + + +class DCA(IDCA): + """Concrete class for DCA groups""" + + @property + def address(self) -> str: + return f"/dca/{self.index}" + + @property + def on(self) -> bool: + return self.getter("on")[0] == 1 + + @on.setter + def on(self, val: bool): + if not isinstance(val, bool): + raise MAirRemoteError("on is a boolean parameter") + self.setter("on", 1 if val else 0) + + @property + def name(self) -> str: + return self.getter("config/name")[0] + + @name.setter + def name(self, val: str): + if not isinstance(val, str): + raise MAirRemoteError("name is a str parameter") + self.setter("config/name")[0] + + @property + def color(self) -> int: + return self.getter("config/color")[0] + + @color.setter + def color(self, val: int): + if not isinstance(val, int): + raise MAirRemoteError("color is an int parameter") + self.setter("config/color", val) diff --git a/mair/errors.py b/mair/errors.py new file mode 100644 index 0000000..d370b9e --- /dev/null +++ b/mair/errors.py @@ -0,0 +1,4 @@ +class MAirRemoteError(Exception): + """Base error class for MAIR Remote.""" + + pass diff --git a/mair/fx.py b/mair/fx.py new file mode 100644 index 0000000..12435b7 --- /dev/null +++ b/mair/fx.py @@ -0,0 +1,82 @@ +import abc +from .errors import MAirRemoteError +from .shared import ( + Config, + Preamp, + Gate, + Dyn, + Insert, + EQ, + GEQ, + Mix, + Group, + Automix, +) + + +class IFX(abc.ABC): + """Abstract Base Class for fxs""" + + def __init__(self, remote, index: int): + self._remote = remote + self.index = index + 1 + + def getter(self, param: str): + self._remote.send(f"{self.address}/{param}") + return self._remote.info_response + + def setter(self, param: str, val: int): + self._remote.send(f"{self.address}/{param}", val) + + @abc.abstractmethod + def address(self): + pass + + +class FXSend(IFX): + """Concrete class for fxsend""" + + @classmethod + def make(cls, remote, index): + """ + Factory function for FXSend + + Creates a mixin of shared subclasses, sets them as class attributes. + + Returns an FXSend class of a kind. + """ + FXSEND_cls = type( + f"FXSend{remote.kind.id_}", + (cls,), + { + **{ + _cls.__name__.lower(): type( + f"{_cls.__name__}{remote.kind.id_}", (_cls, cls), {} + )(remote, index) + for _cls in (Config, Mix, Group) + } + }, + ) + return FXSEND_cls(remote, index) + + @property + def address(self) -> str: + return f"/fxsend/{self.index}" + + +class FXReturn(IFX): + """Concrete class for fxreturn""" + + @property + def address(self) -> str: + return f"/fx/{self.index}" + + @property + def type(self) -> int: + return self.getter("type")[0] + + @type.setter + def type(self, val: int): + if not isinstance(val, int): + raise MAirRemoteError("type is an integer parameter") + self.setter("type", val) diff --git a/mair/kinds.py b/mair/kinds.py new file mode 100644 index 0000000..9e75946 --- /dev/null +++ b/mair/kinds.py @@ -0,0 +1,19 @@ +from dataclasses import dataclass + + +@dataclass +class MR18KindMap: + id_: str = "MR18" + num_dca: int = 4 + num_strip: int = 16 + num_bus: int = 6 + num_fx: int = 4 + num_rtn: int = 4 + + +_kinds = { + "XR18": MR18KindMap(), + "MR18": MR18KindMap(), +} + +all = list(kind for kind in _kinds.values()) diff --git a/mair/lr.py b/mair/lr.py new file mode 100644 index 0000000..49bf112 --- /dev/null +++ b/mair/lr.py @@ -0,0 +1,70 @@ +import abc +from .errors import MAirRemoteError +from .shared import ( + Config, + Preamp, + Gate, + Dyn, + Insert, + EQ, + GEQ, + Mix, + Group, + Automix, +) + + +class ILR(abc.ABC): + """Abstract Base Class for buses""" + + def __init__(self, remote): + self._remote = remote + + def getter(self, param: str): + self._remote.send(f"{self.address}/{param}") + return self._remote.info_response + + def setter(self, param: str, val: int): + self._remote.send(f"{self.address}/{param}", val) + + @abc.abstractmethod + def address(self): + pass + + +class LR(ILR): + """Concrete class for buses""" + + @classmethod + def make(cls, remote): + """ + Factory function for LR + + Creates a mixin of shared subclasses, sets them as class attributes. + + Returns an LR class of a kind. + """ + LR_cls = type( + f"LR{remote.kind.id_}", + (cls,), + { + **{ + _cls.__name__.lower(): type( + f"{_cls.__name__}{remote.kind.id_}", (_cls, cls), {} + )(remote) + for _cls in ( + Config, + Dyn, + Insert, + GEQ.make(), + EQ.make_sixband(cls, remote), + Mix, + ) + }, + }, + ) + return LR_cls(remote) + + @property + def address(self) -> str: + return f"/lr" diff --git a/mair/mair.py b/mair/mair.py new file mode 100644 index 0000000..64a089a --- /dev/null +++ b/mair/mair.py @@ -0,0 +1,138 @@ +import abc +import time +import threading +from pythonosc.dispatcher import Dispatcher +from pythonosc.osc_server import BlockingOSCUDPServer +from pythonosc.osc_message_builder import OscMessageBuilder +from configparser import ConfigParser +from pathlib import Path +from typing import Union + +from . import kinds +from .lr import LR +from .strip import Strip +from .bus import Bus +from .dca import DCA +from .fx import FXSend, FXReturn +from .config import Config +from .rtn import Aux, Rtn + + +class OSCClientServer(BlockingOSCUDPServer): + def __init__(self, address, dispatcher): + super().__init__(("", 0), dispatcher) + self.xr_address = address + + def send_message(self, address, value): + builder = OscMessageBuilder(address=address) + if value is None: + values = list() + elif isinstance(value, list): + values = value + else: + values = [value] + for val in values: + builder.add_arg(val) + msg = builder.build() + self.socket.sendto(msg.dgram, self.xr_address) + + +class MAirRemote(abc.ABC): + """ + Handles the communication with the M-Air mixer via the OSC protocol + """ + + _CONNECT_TIMEOUT = 0.5 + _WAIT_TIME = 0.025 + _REFRESH_TIMEOUT = 5 + + XAIR_PORT = 10024 + + info_response = [] + + def __init__(self, **kwargs): + dispatcher = Dispatcher() + dispatcher.set_default_handler(self.msg_handler) + self.xair_ip = kwargs["ip"] or self._ip_from_ini() + self.server = OSCClientServer((self.xair_ip, self.XAIR_PORT), dispatcher) + + def __enter__(self): + self.worker = threading.Thread(target=self.run_server) + self.worker.daemon = True + self.worker.start() + self.validate_connection() + return self + + def _ip_from_ini(self): + ini_path = Path.cwd() / "config.ini" + parser = ConfigParser() + if not parser.read(ini_path): + print("Could not read config file") + return parser["connection"].get("ip") + + def validate_connection(self): + self.send("/xinfo") + time.sleep(self._CONNECT_TIMEOUT) + if len(self.info_response) > 0: + print(f"Successfully connected to {self.info_response[2]}.") + else: + print( + "Error: Failed to setup OSC connection to mixer. Please check for correct ip address." + ) + + def run_server(self): + self.server.serve_forever() + + def msg_handler(self, addr, *data): + self.info_response = data[:] + + def send(self, address, param=None): + self.server.send_message(address, param) + time.sleep(self._WAIT_TIME) + + def _query(self, address): + self.send(address) + time.sleep(self._WAIT_TIME) + return self.info_response + + def __exit__(self, exc_type, exc_value, exc_tr): + self.server.shutdown() + + +def _make_remote(kind: kinds.MR18KindMap) -> MAirRemote: + """ + Creates a new MAIR remote class. + + The returned class will subclass MAirRemote. + """ + + def init(self, *args, **kwargs): + defaultkwargs = {"ip": None} + kwargs = defaultkwargs | kwargs + MAirRemote.__init__(self, *args, **kwargs) + self.kind = kind + self.lr = LR.make(self) + self.strip = tuple(Strip.make(self, i) for i in range(kind.num_strip)) + self.bus = tuple(Bus.make(self, i) for i in range(kind.num_bus)) + self.dca = tuple(DCA(self, i) for i in range(kind.num_dca)) + self.fxsend = tuple(FXSend.make(self, i) for i in range(kind.num_fx)) + self.fxreturn = tuple(FXReturn(self, i) for i in range(kind.num_fx)) + self.config = Config.make(self) + self.aux = Aux.make(self) + self.rtn = tuple(Rtn.make(self, i) for i in range(kind.num_rtn)) + + return type( + f"MAirRemote{kind.id_}", + (MAirRemote,), + { + "__init__": init, + }, + ) + + +_remotes = {kind.id_: _make_remote(kind) for kind in kinds.all} + + +def connect(kind_id: str, *args, **kwargs): + MAIRREMOTE_cls = _remotes[kind_id] + return MAIRREMOTE_cls(*args, **kwargs) diff --git a/mair/meta.py b/mair/meta.py new file mode 100644 index 0000000..7d54f82 --- /dev/null +++ b/mair/meta.py @@ -0,0 +1,77 @@ +from .errors import MAirRemoteError +from .util import lin_get, lin_set + + +def bool_prop(param): + """A boolean property object.""" + + def fget(self): + return self.getter(param)[0] == 1 + + def fset(self, val): + if not isinstance(val, bool): + raise MAirRemoteError(f"{param} is a boolean parameter") + self.setter(param, 1 if val else 0) + + return property(fget, fset) + + +def string_prop(param): + """A string property object""" + + def fget(self): + return self.getter(param)[0] + + def fset(self, val): + if not isinstance(val, str): + raise MAirRemoteError(f"{param} is a string parameter") + self.setter(param, val) + + return property(fget, fset) + + +def int_prop(param): + """An integer property object""" + + def fget(self): + return int(self.getter(param)[0]) + + def fset(self, val): + if not isinstance(val, int): + raise MAirRemoteError(f"{param} is an integer parameter") + self.setter(param, val) + + return property(fget, fset) + + +def float_prop(param): + """A float property object""" + + def fget(self): + return round(self.getter(param)[0], 1) + + def fset(self, val): + if not isinstance(val, int): + raise MAirRemoteError(f"{param} is a float parameter") + self.setter(param, val) + + return property(fget, fset) + + +def geq_prop(param): + # fmt: off + opts = { + "1k": 1000, "1k25": 1250, "1k6": 1600, "2k": 2000, "3k15": 3150, "4k": 4000, + "5k": 5000, "6k3": 6300, "8k": 8000, "10k": 10000, "12k5": 12500, "16k": 16000, + "20k": 20000, + } + # fmt: on + param = param.replace("_", ".") + + def fget(self) -> float: + return round(lin_get(-15, 15, self.getter(param)[0]), 1) + + def fset(self, val): + self.setter(param, lin_set(-15, 15, val)) + + return property(fget, fset) diff --git a/mair/rtn.py b/mair/rtn.py new file mode 100644 index 0000000..2455a66 --- /dev/null +++ b/mair/rtn.py @@ -0,0 +1,109 @@ +import abc +from typing import Optional +from .errors import MAirRemoteError +from .shared import ( + Config, + Preamp, + Gate, + Dyn, + Insert, + EQ, + GEQ, + Mix, + Group, + Automix, +) + + +class IRtn(abc.ABC): + """Abstract Base Class for aux""" + + def __init__(self, remote, index: Optional[int] = None): + self._remote = remote + if index is not None: + self.index = index + 1 + + def getter(self, param: str): + self._remote.send(f"{self.address}/{param}") + return self._remote.info_response + + def setter(self, param: str, val: int): + self._remote.send(f"{self.address}/{param}", val) + + @abc.abstractmethod + def address(self): + pass + + +class Aux(IRtn): + """Concrete class for aux""" + + @classmethod + def make(cls, remote): + """ + Factory function for aux + + Creates a mixin of shared subclasses, sets them as class attributes. + + Returns an Aux class of a kind. + """ + AUX_cls = type( + f"Aux{remote.kind.id_}", + (cls,), + { + **{ + _cls.__name__.lower(): type( + f"{_cls.__name__}{remote.kind.id_}", (_cls, cls), {} + )(remote) + for _cls in ( + Config, + Preamp, + EQ.make_fourband(cls, remote), + Mix, + Group, + ) + } + }, + ) + return AUX_cls(remote) + + @property + def address(self): + return "/rtn/aux" + + +class Rtn(IRtn): + """Concrete class for rtn""" + + @classmethod + def make(cls, remote, index): + """ + Factory function for rtn + + Creates a mixin of shared subclasses, sets them as class attributes. + + Returns an Rtn class of a kind. + """ + RTN_cls = type( + f"Rtn{remote.kind.id_}", + (cls,), + { + **{ + _cls.__name__.lower(): type( + f"{_cls.__name__}{remote.kind.id_}", (_cls, cls), {} + )(remote, index) + for _cls in ( + Config, + Preamp, + EQ.make_fourband(cls, remote, index), + Mix, + Group, + ) + } + }, + ) + return RTN_cls(remote, index) + + @property + def address(self): + return f"/rtn/{self.index}" diff --git a/mair/shared.py b/mair/shared.py new file mode 100644 index 0000000..970603c --- /dev/null +++ b/mair/shared.py @@ -0,0 +1,680 @@ +from typing import Union + +from .errors import MAirRemoteError +from .util import lin_get, lin_set, log_get, log_set, _get_fader_val, _set_fader_val +from .meta import geq_prop + +""" +Classes shared by /ch, /rtn, /rt/aux, /bus, /fxsend, /lr +""" + + +class Config: + @property + def address(self) -> str: + root = super(Config, self).address + return f"{root}/config" + + @property + def name(self) -> str: + return self.getter("name")[0] + + @name.setter + def name(self, val: str): + if not isinstance(val, str): + raise MAirRemoteError("name is a string parameter") + self.setter("name", val) + + @property + def color(self) -> int: + return self.getter("color")[0] + + @color.setter + def color(self, val: int): + if not isinstance(val, int): + raise MAirRemoteError("color is an int parameter") + self.setter("color", val) + + @property + def inputsource(self) -> int: + return self.getter("insrc")[0] + + @inputsource.setter + def inputsource(self, val: int): + if not isinstance(val, int): + raise MAirRemoteError("inputsource is an int parameter") + self.setter("insrc", val) + + @property + def usbreturn(self) -> int: + return self.getter("rtnsrc")[0] + + @usbreturn.setter + def usbreturn(self, val: int): + if not isinstance(val, int): + raise MAirRemoteError("usbreturn is an int parameter") + self.setter("rtnsrc", val) + + +class Preamp: + @property + def address(self) -> str: + root = super(Preamp, self).address + return f"{root}/preamp" + + @property + def usbtrim(self) -> float: + return round(lin_get(-18, 18, self.getter("rtntrim")[0]), 1) + + @usbtrim.setter + def usbtrim(self, val: float): + if not isinstance(val, float): + raise MAirRemoteError( + "usbtrim is a float parameter, expected value in range -18 to 18" + ) + self.setter("rtntrim", lin_set(-18, 18, val)) + + @property + def usbinput(self) -> bool: + return self.getter("rtnsw")[0] == 1 + + @usbinput.setter + def usbinput(self, val: bool): + if not isinstance(val, bool): + raise MAirRemoteError("rtnsw is a bool parameter") + self.setter("rtnsw", 1 if val else 0) + + @property + def invert(self) -> bool: + return self.getter("invert")[0] == 1 + + @invert.setter + def invert(self, val: bool): + if not isinstance(val, bool): + raise MAirRemoteError("invert is a bool parameter") + self.setter("invert", 1 if val else 0) + + @property + def highpasson(self) -> bool: + return self.getter("hpon")[0] == 1 + + @highpasson.setter + def highpasson(self, val: bool): + if not isinstance(val, bool): + raise MAirRemoteError("hpon is a bool parameter") + self.setter("hpon", 1 if val else 0) + + @property + def highpassfilter(self) -> int: + return int(log_get(20, 400, self.getter("hpf")[0])) + + @highpassfilter.setter + def highpassfilter(self, val: int): + if not isinstance(val, int): + raise MAirRemoteError("highpassfilter is an int parameter") + self.setter("hpf", log_set(20, 400, val)) + + +class Gate: + @property + def address(self) -> str: + root = super(Gate, self).address + return f"{root}/gate" + + @property + def on(self) -> bool: + return self.getter("on")[0] == 1 + + @on.setter + def on(self, val: bool): + if not isinstance(val, bool): + raise MAirRemoteError("on is a boolean parameter") + self.setter("on", 1 if val else 0) + + @property + def mode(self) -> str: + opts = ("gate", "exp2", "exp3", "exp4", "duck") + return opts[self.getter("mode")[0]] + + @mode.setter + def mode(self, val: str): + opts = ("gate", "exp2", "exp3", "exp4", "duck") + if not isinstance(val, str) and val not in opts: + raise MAirRemoteError(f"mode is a string parameter, expected one of {opts}") + self.setter("mode", opts.index(val)) + + @property + def threshold(self) -> float: + return round(lin_get(-80, 0, self.getter("thr")[0]), 1) + + @threshold.setter + def threshold(self, val: float): + if not isinstance(val, float): + raise MAirRemoteError( + "threshold is a float parameter, expected value in range -80 to 0" + ) + self.setter("thr", lin_set(-80, 0, val)) + + @property + def range(self) -> int: + return int(lin_get(3, 60, self.getter("range")[0])) + + @range.setter + def range(self, val: int): + if not isinstance(val, int): + raise MAirRemoteError( + "range is an int parameter, expected value in range 3 to 60" + ) + self.setter("range", lin_set(3, 60, val)) + + @property + def attack(self) -> int: + return int(lin_get(0, 120, self.getter("attack")[0])) + + @attack.setter + def attack(self, val: int): + if not isinstance(val, int): + raise MAirRemoteError( + "attack is an int parameter, expected value in range 0 to 120" + ) + self.setter("attack", lin_set(0, 120, val)) + + @property + def hold(self) -> Union[float, int]: + val = log_get(0.02, 2000, self.getter("hold")[0]) + return round(val, 1) if val < 100 else int(val) + + @hold.setter + def hold(self, val: float): + self.setter("hold", log_set(0.02, 2000, val)) + + @property + def release(self) -> int: + return int(log_get(5, 4000, self.getter("release")[0])) + + @release.setter + def release(self, val: int): + if not isinstance(val, int): + raise MAirRemoteError( + "release is an int parameter, expected value in range 5 to 4000" + ) + self.setter("release", log_set(5, 4000, val)) + + @property + def keysource(self): + return self.getter("keysrc")[0] + + @keysource.setter + def keysource(self, val): + if not isinstance(val, int): + raise MAirRemoteError("keysource is an int parameter") + self.setter("keysrc", val) + + @property + def filteron(self): + return self.getter("filter/on")[0] == 1 + + @filteron.setter + def filteron(self, val: bool): + if not isinstance(val, bool): + raise MAirRemoteError("filteron is a boolean parameter") + self.setter("filter/on", 1 if val else 0) + + @property + def filtertype(self) -> int: + return int(self.getter("filter/type")[0]) + + @filtertype.setter + def filtertype(self, val: int): + if not isinstance(val, int): + raise MAirRemoteError("filtertype is an int parameter") + self.setter("filter/type", val) + + @property + def filterfreq(self) -> Union[float, int]: + retval = log_get(20, 20000, self.getter("filter/f")[0]) + return int(retval) if retval > 1000 else round(retval, 1) + + @filterfreq.setter + def filterfreq(self, val: Union[float, int]): + self.setter("filter/f", log_set(20, 20000, val)) + + +class Dyn: + @property + def address(self) -> str: + root = super(Dyn, self).address + return f"{root}/dyn" + + @property + def on(self) -> bool: + return self.getter("on")[0] == 1 + + @on.setter + def on(self, val: bool): + if not isinstance(val, bool): + raise MAirRemoteError("on is a boolean parameter") + self.setter("on", 1 if val else 0) + + @property + def mode(self) -> str: + opts = ("comp", "exp") + return opts[self.getter("mode")[0]] + + @mode.setter + def mode(self, val: str): + opts = ("comp", "exp") + if not isinstance(val, str) and val not in opts: + raise MAirRemoteError(f"mode is a string parameter, expected one of {opts}") + self.setter("mode", opts.index(val)) + + @property + def det(self) -> str: + opts = ("peak", "rms") + return opts[self.getter("det")[0]] + + @det.setter + def det(self, val: str): + opts = ("peak", "rms") + if not isinstance(val, str) and val not in opts: + raise MAirRemoteError(f"det is a string parameter, expected one of {opts}") + self.setter("det", opts.index(val)) + + @property + def env(self) -> str: + opts = ("lin", "log") + return opts[self.getter("env")[0]] + + @env.setter + def env(self, val: str): + opts = ("lin", "log") + if not isinstance(val, str) and val not in opts: + raise MAirRemoteError(f"env is a string parameter, expected one of {opts}") + self.setter("env", opts.index(val)) + + @property + def threshold(self) -> float: + return round(lin_get(-60, 0, self.getter("thr")[0]), 1) + + @threshold.setter + def threshold(self, val: float): + if not isinstance(val, float): + raise MAirRemoteError( + "threshold is a float parameter, expected value in range -80 to 0" + ) + self.setter("thr", lin_set(-60, 0, val)) + + @property + def ratio(self) -> Union[float, int]: + opts = (1.1, 1.3, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0, 7.0, 10, 20, 100) + return opts[self.getter("ratio")[0]] + + @ratio.setter + def ratio(self, val: int): + if not isinstance(val, int): + raise MAirRemoteError("ratio is an int parameter") + self.setter("ratio", val) + + @property + def knee(self) -> int: + return int(lin_get(0, 5, self.getter("knee")[0])) + + @knee.setter + def knee(self, val: int): + if not isinstance(val, int): + raise MAirRemoteError( + "knee is an int parameter, expected value in range 0 to 5" + ) + self.setter("knee", lin_set(0, 5, val)) + + @property + def mgain(self) -> float: + return round(lin_get(0, 24, self.getter("mgain")[0]), 1) + + @mgain.setter + def mgain(self, val: float): + self.setter("mgain", lin_set(0, 24, val)) + + @property + def attack(self) -> int: + return int(lin_get(0, 120, self.getter("attack")[0])) + + @attack.setter + def attack(self, val: int): + self.setter("attack", lin_set(0, 120, val)) + + @property + def hold(self) -> Union[float, int]: + val = log_get(0.02, 2000, self.getter("hold")[0]) + return round(val, 1) if val < 100 else int(val) + + @hold.setter + def hold(self, val: float): + self.setter("hold", log_set(0.02, 2000, val)) + + @property + def release(self) -> int: + return int(log_get(5, 4000, self.getter("release")[0])) + + @release.setter + def release(self, val: int): + if not isinstance(val, int): + raise MAirRemoteError( + "release is an int parameter, expected value in range 5 to 4000" + ) + self.setter("release", log_set(5, 4000, val)) + + @property + def mix(self) -> int: + return int(lin_get(0, 100, self.getter("mix")[0])) + + @mix.setter + def mix(self, val: int): + if not isinstance(val, int): + raise MAirRemoteError( + "mix is an int parameter, expected value in range 0 to 5" + ) + self.setter("mix", lin_set(0, 100, val)) + + @property + def keysource(self): + return self.getter("keysrc")[0] + + @keysource.setter + def keysource(self, val): + if not isinstance(val, int): + raise MAirRemoteError("keysource is an int parameter") + self.setter("keysrc", val) + + @property + def auto(self) -> bool: + return self.getter("auto")[0] == 1 + + @auto.setter + def auto(self, val: bool): + if not isinstance(val, bool): + raise MAirRemoteError("auto is a boolean parameter") + self.setter("auto", 1 if val else 0) + + @property + def filteron(self): + return self.getter("filter/on")[0] == 1 + + @filteron.setter + def filteron(self, val: bool): + if not isinstance(val, bool): + raise MAirRemoteError("filteron is a boolean parameter") + self.setter("filter/on", 1 if val else 0) + + @property + def filtertype(self) -> int: + return int(self.getter("filter/type")[0]) + + @filtertype.setter + def filtertype(self, val: int): + if not isinstance(val, int): + raise MAirRemoteError("filtertype is an int parameter") + self.setter("filter/type", val) + + @property + def filterfreq(self) -> Union[float, int]: + retval = log_get(20, 20000, self.getter("filter/f")[0]) + return int(retval) if retval > 1000 else round(retval, 1) + + @filterfreq.setter + def filterfreq(self, val: Union[float, int]): + self.setter("filter/f", log_set(20, 20000, val)) + + +class Insert: + @property + def address(self) -> str: + root = super(Insert, self).address + return f"{root}/insert" + + @property + def on(self) -> bool: + return self.getter("on")[0] == 1 + + @on.setter + def on(self, val: bool): + if not isinstance(val, bool): + raise MAirRemoteError("on is a boolean parameter") + self.setter("on", 1 if val else 0) + + @property + def sel(self) -> int: + return self.getter("sel")[0] + + @sel.setter + def sel(self, val: int): + if not isinstance(val, int): + raise MAirRemoteError("sel is an int parameter") + self.setter("sel", val) + + +class EQ: + @classmethod + def make_fourband(cls, _cls, remote, index=None): + EQBand_cls = type("EQBand", (EQ.EQBand, _cls), {}) + return type( + "EQ", + (cls,), + { + "low": EQBand_cls(1, remote, index), + "lomid": EQBand_cls(2, remote, index), + "himid": EQBand_cls(3, remote, index), + "high": EQBand_cls(4, remote, index), + }, + ) + + @classmethod + def make_sixband(cls, _cls, remote, index=None): + EQBand_cls = type("EQBand", (EQ.EQBand, _cls), {}) + return type( + "EQ", + (cls,), + { + "low": EQBand_cls(1, remote, index), + "low2": EQBand_cls(2, remote, index), + "lomid": EQBand_cls(3, remote, index), + "himid": EQBand_cls(4, remote, index), + "high2": EQBand_cls(5, remote, index), + "high": EQBand_cls(6, remote, index), + }, + ) + + @property + def address(self) -> str: + root = super(EQ, self).address + return f"{root}/eq" + + @property + def on(self) -> bool: + return self.getter("on")[0] == 1 + + @on.setter + def on(self, val: bool): + if not isinstance(val, bool): + raise MAirRemoteError("on is a boolean parameter") + self.setter("on", 1 if val else 0) + + @property + def mode(self) -> str: + opts = ("peq", "geq", "teq") + return opts[self.getter("mode")[0]] + + @mode.setter + def mode(self, val: str): + opts = ("peq", "geq", "teq") + if not isinstance(val, str) and val not in opts: + raise MAirRemoteError(f"mode is a string parameter, expected one of {opts}") + self.setter("mode", opts.index(val)) + + class EQBand: + def __init__(self, i, remote, index): + if index is None: + super(EQ.EQBand, self).__init__(remote) + else: + super(EQ.EQBand, self).__init__(remote, index) + self.i = i + + @property + def address(self) -> str: + root = super(EQ.EQBand, self).address + return f"{root}/eq/{self.i}" + + @property + def type(self) -> int: + return int(self.getter("type")[0]) + + @type.setter + def type(self, val: int): + if not isinstance(val, int): + raise MAirRemoteError("type is an int parameter") + self.setter(f"type", val) + + @property + def frequency(self) -> float: + retval = log_get(20, 20000, self.getter("f")[0]) + return round(retval, 1) + + @frequency.setter + def frequency(self, val: float): + self.setter("f", log_set(20, 20000, val)) + + @property + def gain(self) -> float: + return round(lin_get(-15, 15, self.getter("g")[0]), 1) + + @gain.setter + def gain(self, val: float): + self.setter("g", lin_set(-15, 15, val)) + + @property + def quality(self) -> float: + retval = log_get(0.3, 10, self.getter("q")[0]) + return round(retval, 1) + + @quality.setter + def quality(self, val: float): + self.setter("q", log_set(0.3, 10, val)) + + +class GEQ: + @classmethod + def make(cls): + # fmt: off + return type( + "GEQ", + (cls,), + { + **{ + f"slider_{param}": geq_prop(param) + for param in [ + "20", "25", "31_5", "40", "50", "63", "80", "100", "125", + "160", "200", "250", "315" "400", "500", "630", "800", "1k", + "1k25", "1k6", "2k", "2k5", "3k15", "4k", "5k", "6k3", "8k", + "10k", "12k5", "16k", "20k", + ] + } + }, + ) + # fmt: on + + @property + def address(self) -> str: + root = super(GEQ, self).address + return f"{root}/geq" + + +class Mix: + @property + def address(self) -> str: + root = super(Mix, self).address + return f"{root}/mix" + + @property + def on(self) -> bool: + return self.getter("on")[0] == 1 + + @on.setter + def on(self, val: bool): + if not isinstance(val, bool): + raise MAirRemoteError("on is a boolean parameter") + self.setter("on", 1 if val else 0) + + @property + def fader(self) -> float: + retval = self.getter("fader")[0] + return _get_fader_val(retval) + + @fader.setter + def fader(self, val: float): + _set_fader_val(self, val) + + @property + def lr(self) -> bool: + return self.getter("lr")[0] == 1 + + @lr.setter + def lr(self, val: bool): + if not isinstance(val, bool): + raise MAirRemoteError("lr is a boolean parameter") + self.setter("lr", 1 if val else 0) + + +class Group: + @property + def address(self) -> str: + root = super(Group, self).address + return f"{root}/grp" + + @property + def dca(self) -> int: + return self.getter("dca")[0] + + @dca.setter + def dca(self, val: int): + if not isinstance(val, int): + raise MAirRemoteError("dca is an int parameter") + self.setter("dca", val) + + @property + def mute(self) -> int: + return self.getter("mute")[0] + + @mute.setter + def mute(self, val: int): + if not isinstance(val, int): + raise MAirRemoteError("mute is an int parameter") + self.setter("mute", val) + + +class Automix: + @property + def address(self) -> str: + root = super(Automix, self).address + return f"{root}/automix" + + @property + def group(self) -> int: + return self.getter("group")[0] + + @group.setter + def group(self, val: int): + if not isinstance(val, int): + raise MAirRemoteError("group is an int parameter") + self.setter("group", val) + + @property + def weight(self) -> float: + return round(lin_get(-12, 12, self.getter("weight")[0]), 1) + + @weight.setter + def weight(self, val: float): + if not isinstance(val, float): + raise MAirRemoteError( + "weight is a float parameter, expected value in range -12 to 12" + ) + self.setter("weight", lin_set(-12, 12, val)) diff --git a/mair/strip.py b/mair/strip.py new file mode 100644 index 0000000..64fa190 --- /dev/null +++ b/mair/strip.py @@ -0,0 +1,74 @@ +import abc +from .errors import MAirRemoteError +from .shared import ( + Config, + Preamp, + Gate, + Dyn, + Insert, + EQ, + GEQ, + Mix, + Group, + Automix, +) + + +class IStrip(abc.ABC): + """Abstract Base Class for strips""" + + def __init__(self, remote, index: int): + self._remote = remote + self.index = index + 1 + + def getter(self, param: str) -> tuple: + self._remote.send(f"{self.address}/{param}") + return self._remote.info_response + + def setter(self, param: str, val: int): + self._remote.send(f"{self.address}/{param}", val) + + @abc.abstractmethod + def address(self): + pass + + +class Strip(IStrip): + """Concrete class for strips""" + + @classmethod + def make(cls, remote, index): + """ + Factory function for strips + + Creates a mixin of shared subclasses, sets them as class attributes. + + Returns a Strip class of a kind. + """ + STRIP_cls = type( + f"Strip{remote.kind.id_}", + (cls,), + { + **{ + _cls.__name__.lower(): type( + f"{_cls.__name__}{remote.kind.id_}", (_cls, cls), {} + )(remote, index) + for _cls in ( + Config, + Preamp, + Gate, + Dyn, + Insert, + EQ.make_fourband(cls, remote, index), + Mix, + Group, + Automix, + ) + }, + }, + ) + return STRIP_cls(remote, index) + + @property + def address(self) -> str: + return f"/ch/{str(self.index).zfill(2)}" diff --git a/mair/util.py b/mair/util.py new file mode 100644 index 0000000..4986a5d --- /dev/null +++ b/mair/util.py @@ -0,0 +1,77 @@ +from math import log, exp + + +def lin_get(min, max, val): + return min + (max - min) * val + + +def lin_set(min, max, val): + return (val - min) / (max - min) + + +def log_get(min, max, val): + return min * exp(log(max / min) * val) + + +def log_set(min, max, val): + return log(val / min) / log(max / min) + + +def _get_fader_val(retval): + if retval >= 1: + return 10 + elif retval >= 0.5: + return round((40 * retval) - 30, 1) + elif retval >= 0.25: + return round((80 * retval) - 50, 1) + elif retval >= 0.0625: + return round((160 * retval) - 70, 1) + elif retval >= 0: + return round((480 * retval) - 90, 1) + else: + return -90 + + +def _set_fader_val(self, val): + if val >= 10: + self.setter("fader", 1) + elif val >= -10: + self.setter("fader", (val + 30) / 40) + elif val >= -30: + self.setter("fader", (val + 50) / 80) + elif val >= -60: + self.setter("fader", (val + 70) / 160) + elif val >= -90: + self.setter("fader", (val + 90) / 480) + else: + self.setter("fader", 0) + + +def _get_level_val(retval): + if retval >= 1: + return 10 + elif retval >= 0.5: + return round((40 * retval) - 30, 1) + elif retval >= 0.25: + return round((80 * retval) - 50, 1) + elif retval >= 0.0625: + return round((160 * retval) - 70, 1) + elif retval >= 0: + return round((480 * retval) - 90, 1) + else: + return -90 + + +def _set_level_val(self, val): + if val >= 10: + self.setter("level", 1) + elif val >= -10: + self.setter("level", (val + 30) / 40) + elif val >= -30: + self.setter("level", (val + 50) / 80) + elif val >= -60: + self.setter("level", (val + 70) / 160) + elif val >= -90: + self.setter("level", (val + 90) / 480) + else: + self.setter("level", 0) diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..79c1a55 --- /dev/null +++ b/setup.py @@ -0,0 +1,18 @@ +from setuptools import setup + +setup( + name='mair_remote', + version='0.1', + description='MAIR Remote Python API', + packages=['mair'], + install_requires=[ + 'python-osc' + ], + extras_require={ + 'development': [ + 'nose', + 'randomize', + 'parameterized' + ] + } +) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..b85a1dc --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,17 @@ +import mair +from mair import kinds +import threading + +_kind = 'MR18' + +mars = {kind.id_: mair.connect(_kind) for kind in kinds.all} +tests = mars[_kind] + +def setup_package(): + tests.worker = threading.Thread(target = tests.run_server) + tests.worker.daemon = True + tests.worker.start() + tests.validate_connection() + +def teardown_package(): + tests.server.shutdown() diff --git a/tests/shared_tests.py b/tests/shared_tests.py new file mode 100644 index 0000000..0808bfb --- /dev/null +++ b/tests/shared_tests.py @@ -0,0 +1,332 @@ +from nose.tools import assert_equal, nottest +from parameterized import parameterized, parameterized_class + +import unittest +from tests import tests + +""" +Not every subclass is tested for every superclass to avoid redundancy. +LR: mix, config, insert, geq +Strip: mix, preamp, config, gate, automix +Bus: config, dyn, eq +FXSend: group +""" + +""" LR TESTS """ +#@nottest +class TestSetAndGetLRMixHigher(unittest.TestCase): + """ Mix """ + def setUp(self): + self.target = getattr(tests, 'lr') + self.target = getattr(self.target, 'mix') + + @parameterized.expand([ + ('on', True), ('on', False) + ]) + def test_it_sets_and_gets_lr_bool_params(self, param, val): + setattr(self.target, param, val) + retval = getattr(self.target, param) + self.assertTrue(isinstance(retval, bool)) + assert_equal(retval, val) + + @parameterized.expand([ + ('fader', -80.6), ('fader', -67.0) + ]) + def test_it_sets_and_gets_lr_float_params(self, param, val): + setattr(self.target, param, val) + retval = getattr(self.target, param) + assert_equal(retval, val) + +#@nottest +class TestSetAndGetLRConfigHigher(unittest.TestCase): + """ Config """ + def setUp(self): + self.target = getattr(tests, 'lr') + self.target = getattr(self.target, 'config') + + @parameterized.expand([ + ('name', 'test0'), ('name', 'test1') + ]) + def test_it_sets_and_gets_lr_string_params(self, param, val): + setattr(self.target, param, val) + retval = getattr(self.target, param) + assert_equal(retval, val) + +#@nottest +class TestSetAndGetLRInsertHigher(unittest.TestCase): + """ Insert """ + def setUp(self): + self.target = getattr(tests, 'lr') + self.target = getattr(self.target, 'insert') + + @parameterized.expand([ + ('on', True), ('on', False) + ]) + def test_it_sets_and_gets_lr_bool_params(self, param, val): + setattr(self.target, param, val) + retval = getattr(self.target, param) + self.assertTrue(isinstance(retval, bool)) + assert_equal(retval, val) + + @parameterized.expand([ + ('sel', 0), ('sel', 4) + ]) + def test_it_sets_and_gets_lr_int_params(self, param, val): + setattr(self.target, param, val) + retval = getattr(self.target, param) + assert_equal(retval, val) + +#@nottest +class TestSetAndGetLRGEQHigher(unittest.TestCase): + """ GEQ """ + def setUp(self): + self.target = getattr(tests, 'lr') + self.target = getattr(self.target, 'geq') + + @parameterized.expand([ + ('slider_20', -13.5), ('slider_20', 5.5), ('slider_6k3', -8.5), ('slider_6k3', 8.5) + ]) + def test_it_sets_and_gets_lr_int_params(self, param, val): + setattr(self.target, param, val) + retval = getattr(self.target, param) + assert_equal(retval, val) + + +""" STRIP TESTS """ +#@nottest +@parameterized_class([ + { 'i': 15 } +]) +class TestSetAndGetStripMixHigher(unittest.TestCase): + """ Mix """ + def setUp(self): + self.target = getattr(tests, 'strip') + self.target = getattr(self.target[self.i], 'mix') + + @parameterized.expand([ + ('on', True), ('on', False), ('lr', True), ('lr', False) + ]) + def test_it_sets_and_gets_strip_bool_params(self, param, val): + setattr(self.target, param, val) + retval = getattr(self.target, param) + self.assertTrue(isinstance(retval, bool)) + assert_equal(retval, val) + +#@nottest +@parameterized_class([ + { 'i': 8 } +]) +class TestSetAndGetStripPreampHigher(unittest.TestCase): + """ Preamp """ + def setUp(self): + self.target = getattr(tests, 'strip') + self.target = getattr(self.target[self.i], 'preamp') + + @parameterized.expand([ + ('highpasson', True), ('highpasson', False), ('usbinput', True), ('usbinput', False) + ]) + def test_it_sets_and_gets_strip_bool_params(self, param, val): + setattr(self.target, param, val) + retval = getattr(self.target, param) + self.assertTrue(isinstance(retval, bool)) + assert_equal(retval, val) + + @parameterized.expand([ + ('highpassfilter', 20), ('highpassfilter', 399) + ]) + def test_it_sets_and_gets_strip_int_params(self, param, val): + setattr(self.target, param, val) + retval = getattr(self.target, param) + assert_equal(retval, val) + + @parameterized.expand([ + ('usbtrim', -16.5), ('usbtrim', 5.5) + ]) + def test_it_sets_and_gets_strip_float_params(self, param, val): + setattr(self.target, param, val) + retval = getattr(self.target, param) + assert_equal(retval, val) + +#@nottest +@parameterized_class([ + { 'i': 3 } +]) +class TestSetAndGetStripConfigHigher(unittest.TestCase): + """ Config """ + def setUp(self): + self.target = getattr(tests, 'strip') + self.target = getattr(self.target[self.i], 'config') + + @parameterized.expand([ + ('inputsource', 0), ('inputsource', 18), ('usbreturn', 3), ('usbreturn', 12) + ]) + def test_it_sets_and_gets_strip_int_params(self, param, val): + setattr(self.target, param, val) + retval = getattr(self.target, param) + assert_equal(retval, val) + +@parameterized_class([ + { 'i': 12 } +]) +class TestSetAndGetStripGateHigher(unittest.TestCase): + """ Gate """ + def setUp(self): + self.target = getattr(tests, 'strip') + self.target = getattr(self.target[self.i], 'gate') + + @parameterized.expand([ + ('on', True), ('on', False), ('invert', True), ('invert', False), + ('filteron', True), ('filteron', False) + ]) + def test_it_sets_and_gets_strip_bool_params(self, param, val): + setattr(self.target, param, val) + retval = getattr(self.target, param) + self.assertTrue(isinstance(retval, bool)) + assert_equal(retval, val) + + @parameterized.expand([ + ('range', 11), ('range', 48), ('attack', 5), ('attack', 110), + ('release', 360), ('release', 2505), ('filtertype', 0), ('filtertype', 8) + ]) + def test_it_sets_and_gets_strip_int_params(self, param, val): + setattr(self.target, param, val) + retval = getattr(self.target, param) + assert_equal(retval, val) + + @parameterized.expand([ + ('mode', 'exp2'), ('mode', 'duck') + ]) + def test_it_sets_and_gets_strip_string_params(self, param, val): + setattr(self.target, param, val) + retval = getattr(self.target, param) + assert_equal(retval, val) + + @parameterized.expand([ + ('threshold', -80.0), ('threshold', 0.0), ('hold', 355), ('hold', 63.2), + ('filterfreq', 37.2), ('filterfreq', 12765) + ]) + def test_it_sets_and_gets_strip_float_params(self, param, val): + setattr(self.target, param, val) + retval = getattr(self.target, param) + assert_equal(retval, val) + +#@nottest +@parameterized_class([ + { 'i': 6 } +]) +class TestSetAndGetStripAutomixHigher(unittest.TestCase): + """ Automix """ + def setUp(self): + self.target = getattr(tests, 'strip') + self.target = getattr(self.target[self.i], 'automix') + + @parameterized.expand([ + ('group', 0), ('group', 2) + ]) + def test_it_sets_and_gets_fxsend_int_params(self, param, val): + setattr(self.target, param, val) + retval = getattr(self.target, param) + assert_equal(retval, val) + + @parameterized.expand([ + ('weight', -10.5), ('weight', 3.5) + ]) + def test_it_sets_and_gets_fxsend_float_params(self, param, val): + setattr(self.target, param, val) + retval = getattr(self.target, param) + assert_equal(retval, val) + + +""" BUS TESTS """ +#@nottest +@parameterized_class([ + { 'i': 1 } +]) +class TestSetAndGetBusConfigHigher(unittest.TestCase): + """ Config """ + def setUp(self): + self.target = getattr(tests, 'bus') + self.target = getattr(self.target[self.i], 'config') + + @parameterized.expand([ + ('color', 0), ('color', 15) + ]) + def test_it_sets_and_gets_bus_bool_params(self, param, val): + setattr(self.target, param, val) + retval = getattr(self.target, param) + assert_equal(retval, val) + +#@nottest +@parameterized_class([ + { 'i': 2 } +]) +class TestSetAndGetBusDynHigher(unittest.TestCase): + """ Dyn """ + def setUp(self): + self.target = getattr(tests, 'bus') + self.target = getattr(self.target[self.i], 'dyn') + + @parameterized.expand([ + ('on', True), ('on', False) + ]) + def test_it_sets_and_gets_bus_bool_params(self, param, val): + setattr(self.target, param, val) + retval = getattr(self.target, param) + self.assertTrue(isinstance(retval, bool)) + assert_equal(retval, val) + + @parameterized.expand([ + ('mode', 'comp'), ('mode', 'exp'), ('env', 'lin'), ('env', 'log'), + ('det', 'peak'), ('det', 'rms') + ]) + def test_it_sets_and_gets_bus_string_params(self, param, val): + setattr(self.target, param, val) + retval = getattr(self.target, param) + assert_equal(retval, val) + +#@nottest +@parameterized_class([ + { 'i': 0 } +]) +class TestSetAndGetBusEQHigher(unittest.TestCase): + """ EQ """ + def setUp(self): + self.target = getattr(tests, 'bus') + self.target = getattr(self.target[self.i], 'eq') + + @parameterized.expand([ + ('on', True), ('on', False) + ]) + def test_it_sets_and_gets_bus_bool_params(self, param, val): + setattr(self.target, param, val) + retval = getattr(self.target, param) + self.assertTrue(isinstance(retval, bool)) + assert_equal(retval, val) + + @parameterized.expand([ + ('mode', 'peq'), ('mode', 'geq'), ('mode', 'teq') + ]) + def test_it_sets_and_gets_bus_string_params(self, param, val): + setattr(self.target, param, val) + retval = getattr(self.target, param) + assert_equal(retval, val) + + +""" FXSEND TESTS """ +#@nottest +@parameterized_class([ + { 'i': 1 } +]) +class TestSetAndGetFXSendGroupHigher(unittest.TestCase): + """ Group """ + def setUp(self): + self.target = getattr(tests, 'fxsend') + self.target = getattr(self.target[self.i], 'group') + + @parameterized.expand([ + ('dca', 0), ('dca', 12), ('mute', 3), ('mute', 8) + ]) + def test_it_sets_and_gets_fxsend_int_params(self, param, val): + setattr(self.target, param, val) + retval = getattr(self.target, param) + assert_equal(retval, val)