Skip to main content

Reverse Engineering For Fun And (Non)profit

I really hate it when the documentation is missing, or worse, outdated and incomplete. Unfortunately, this is the case for the Behringer XR18 digital mixer.

Background

Let’s step back for a moment to introduce a little background. I’m a Tech volunteer at GSPS Foundation which hosts the Polish bi-yearly (as-in happening twice a year) charity speedrunning marathon based on the formula popularized by Games Done Quick (GDQ). GSPS stands for Gramy Szybko, Pomagamy Skutecznie, which roughly translates to “Gaming Swiftly, Providing Support”1 when we try to keep the acronym.

The next GSPS event will be GSPS Dzieciom 2025. It’s scheduled for March 26-30. You can buy a ticket and visit us in Kraków in person or watch the live transmissions on Twitch and YouTube.

The Foundation owns a digital audio mixer, a Behringer XR18, which offers great mixing capabilities for its price. We currently use all but one of the input channels during the events, so we’re dangerously close to the limit.

GDQ has an interesting way of indicating who is currently speaking on the stream.

GDQ’s audio input indicators
GDQ’s audio input indicators.

It mimics the classic UV meter bars that meet at the center of the speaker’s label. I wondered if it would be possible to implement something similar in our stream layouts because I knew that they use an older brother of our mixer, an X32.

Oh, There’s An API

Behringer’s X-AIR-Edit app is the official way to control the mixer. It somehow manages to display the current signal input level for each channel in nearly real-time. I just needed to know how, so I could use that information in our code.

X-AIR-Edit’s channel input indicators
X-AIR-Edit channel input indicators – the green bars next to the faders.

Onyx, an old friend of mine, has already researched the topic of the API offered by the whole family of Behringer’s digital mixers and wrote a python library for interacting with them. It turns out that they use a standard OSC (Open Sound Control) protocol over UDP.

It’s a simple protocol. Each message consists of an address and a types tag, followed by some values for the fields specified in the aforementioned tag. The protocol requires 4-byte alignment using null bytes. Luckily, there are libraries to handle the protocol, so we don’t have to (yet ;).

I contacted Onyx because I couldn’t find anything in his library that would allow me to query the input levels. He directed me to the official docs, which are allegedly suitable for the firmware version “1.11 or higher” (our mixer runs on the newest, 1.22 firmware). I found what I was looking for. The /meters command was supposed to be an answer to all my needs.

Enter The Docs

The input levels that I was interested in are returned as a “blob” of bytes. For example /meters/2 yields an i32 (the size of the payload) and 36 i16s (16 analog, 2 aux, and 18 USB inputs) that we have to decode manually because the OSC protocol doesn’t specify i16 type, so the OSC libraries will just return the raw bytes. The i16 values’ resolution is specified to be 1/256 dB, that’s pretty simple and more than enough for our needs. To convert them to dB we just need to divide the raw value by 256.

So far so good, I quickly started hacking some PoC to connect to the mixer and request the /meters/2. Everything worked as expected, I could read and decode the values and match them with what I was seeing in the official app.

PoC of the solution.

I was happy that it worked. However, that hasn’t lasted very long. Every time I started my script, it only worked for roughly 10 seconds before freezing. The docs for /meters/ clearly only stated that:

Results in regular updates to batch of meter values as a single binary blob (…)

Debugging

I checked that there were always 200 responses from the mixer every time I started the app and subscribed to the meters. One every 50 ms; that gives the 10 seconds I observed. Perhaps that’s an intended behavior, not a bug.

Technically, I could just re-request the /meters/2 every 200 messages or 10 seconds, but considering that this runs over UDP, there are no guarantees for deliveries or timings.

I decided to sniff the traffic of the official X-AIR-Edit app with Wireshark to see what they do. I knew the mixer’s address and that we’re using UDP, so the filter was a pretty straightforward one, like: udp && ip.src == 192.168.11.167. There was a lot of traffic exchanged between the mixer and the app.

I further refined the filter to look just for the meters: && data contains "meters/1" (they used different combinations of meters to display the same info; /2 is what I ultimately needed, but that’s not important here). That’s when I realized that every two seconds there’s a periodic message from the app to the mixer, that looks like this:

xr vs wireshark
Periodic message sent from the X-AIR-Edit app to the mixer captured in Wireshark.

Huh? What’s that /renew OSC command? I haven’t seen anything about it in the docs.

Renew The Subscription

It turns out that there’s an undocumented feature that refreshes the subscription for the meters. That makes a lot of sense, as it lets the mixer stop sending the data in case of network issues or app failures, we’re using UDP after all.

The syntax is pretty obvious (dots for null bytes):

/renew..,s..meters/1....

,s means that there will be a single null-terminated string in the payload.

I quickly added a periodic /renew call to my code and observed that the mixer now sends me the updates without any interruptions.

Final Integration

GSPS stream layouts are based on the NodeCG framework that supports building interactive graphics and overlays for live broadcasts. I wrapped everything together into a NodeCG extension and added some glue code for passing the state between the extension and the rendered overlays. Here’s a preview of the final effect:

Preview of the final integration in the stream layouts.

I didn’t really like the aggressive animation on the GDQ layouts, so I opted for the subtle background highlights.

The implementation has been submitted to the public repository in this Pull Request.

Closing Words

If you’re offering a public API, please document it properly and keep it updated – it saves developers time and frustration. The Wireshark part in this story could have been entirely avoided if the manufacturer updated the docs.

I’m not entirely sure what’s the case here – was the /renew not available in the 1.11 firmware? Is it an omission and the docs will eventually be updated? Was it a deliberate decision not to share that endpoint, hence rendering the entire /meters feature inconvenient to use?


  1. Shout-out to myamai for chipping in the English GSPS acronym expansion. ↩︎