Abstracting Away From a Base Class
A few years ago I picked up a Midas MR18 Digital Mixer for mixing music and streaming online. After a while I discovered the need to make adjustments programmatically, however, there was no official API so I decided to investigate...
Background
Behringer offer an official app XAIR Edit that can be used to remote control a mixer from an android device or a Windows PC. It uses the OSC protocol over UDP to communicate with the mixer.
Patrick-Gilles Maillot has done a lot of fantastic work (mostly in C) with the XAIR/MAIR series. Of particular help to me was the documentation he'd drawn up along with an X32 Emulator which I found very useful in testing my own code.
Discovering the base class
It turns out that one thing I really enjoy doing is writing interfaces that represent families of products. I've spent a lot of time over the past few years programming with the Voicemeeter API, which itself is a family of products, so I thought why not give it a go here as well.
To start with I went searching to see if anyone had already done this and I stumbled across the Xair-Remote python package by Peter Dikant. It's a useful package that allows a user to connect an X-TOUCH MINI MIDI Controller to an XAIR mixer. With it you can control parameter states including volumes, mute states and bus sends. Digging into the code a little I noticed he'd written a base XAirClient class and used it's send()
method to communicate directly with the mixer. So it occurred to me, perhaps we can decouple this base class from its implementation, write an abstraction layer over it, scale it according to each kind of mixer and present a pythonic interface that represents the XAIR/MAIR family of mixers.
Developing the Interface
Step one, extract the base class
This was mostly a copy-paste, I'm very grateful to the original developer of the Xair-Remote package, it sped up the process of writing this interface.
Step two, lay out the kind maps
Since we want our abstraction layer to scale correctly, it helps us to create dataclasses that map precisely the structure of each kind of mixer.
For example, this kind map would represent the XR18 mixer:
@dataclass
class XR18KindMap(KindMap):
id_: str
num_dca: int = 4
num_strip: int = 16
num_bus: int = 6
num_fx: int = 4
Step three, write the abstraction layer
In writing the abstraction layer I relied heavily on documentation written up by others. I also had to rely somewhat on intuition and a lot of testing. Since I'm not an audio engineer and I only have access to a single product in the family of products at points I just did my best.
I'll take the Strip class as a single example. First, an abstract base class that defines some default implementation:
class IStrip(abc.ABC):
def __init__(self, remote, index: int):
self._remote = remote
self.index = index + 1
def getter(self, param: str) -> tuple:
return self._remote.query(f"{self.address}/{param}")
def setter(self, param: str, val: int):
self._remote.send(f"{self.address}/{param}", val)
@abc.abstractmethod
def address(self):
pass
Then, a concrete class that mixes in a whole bunch of other classes that precisely define the layout for a single strip:
class Strip(IStrip):
@classmethod
def make(cls, remote, index):
STRIP_cls = type(
f"Strip{remote.kind}",
(cls,),
{
**{
_cls.__name__.lower(): type(
f"{_cls.__name__}{remote.kind}", (_cls, cls), {}
)(remote, index)
for _cls in (
Config,
Preamp,
Gate,
...
)
},
...
},
)
return STRIP_cls(remote, index)
@property
def address(self) -> str:
return f"/ch/{str(self.index).zfill(2)}"
Finally, a factory function for composing each XAirRemote{kind}
object:
def init_xair(self, *args, **kwargs):
XAirRemote.__init__(self, *args, **kwargs)
self.kind = kind
self.strip = tuple(Strip.make(self, i) for i in range(kind.num_strip))
Extending the interface to support the X32
When I first wrote the XAIR-API package I'd originally intended to support the XAIR/MAIR series only. Some of the OSC addresses differ slightly for the X32 because it is (physically) a substantially different mixer. Whereas the XAIR/MAIR are digital rack mixers, the X32 if a full blown desk mixer with many more channels and physical controls. However, due to a particular request from a particular user of the interface I decided to investigate support for the X32.
To that end I wrote some adapter classes, for example:
class Bus(IBus):
@property
def address(self):
return f"/bus/{str(self.index).zfill(2)}"
They override the addresses for the XAIR series modifying them according to the X32 specification. In the case of Bus addresses, the XAIR series use /bus/1/
whereas the X32 uses /bus/01
, as you can see numbers are left padded with zeros.
Then I wrote a separate factory function for the x32, using the adapter classes to build the layout for the interface:
def init_x32(self, *args, **kwargs):
XAirRemote.__init__(self, *args, **kwargs)
self.kind = kind
self.bus = tuple(adapter.Bus.make(self, i) for i in range(kind.num_bus))
Conclusion
All in all I found the exercise of decoupling a base class written by another developer and writing it to an interface an eye-opening experience. It forced me to really think about the following:
- The best way to implement the interface internally.
- What it would be like to use from the consumer's perspective.
- Which parts to expose.
- How to present a pythonic interface that abstracts away from the details of OSC.
I have made public the full source code.
Subscribe to this blog's RSS feed