VBAN to Rust to XCtrl and back again

I found another X-Touch quirk. If you give it too much to think about, too often, it'll just fall over. Hats off to the little board inside, it keeps trying to reconnect back out, but never seems to make it. Probably an interesting overflow case going on there somewhere! Turns out sending it one packet that does All The Things™️ doesn't behave as well as I thought it did initially.

During testing, I was incrementally adding more and more things to the packet, expecting it to break somewhere along the line. If the desk falls over though, the everything stops actually updating and starts looking like it's updating. So the desk would go from its idle 'SCAN' state back into displaying what I thought I was sending it, but what was actually just the last state it knew about however many packets ago. Not ideal.

It seems the displays were the problem, pulling them out to their own packet seems to work well. Maybe it's down to them being SysEx messages and behaving differently? I'm not sure, but we've got a pair of packets that can push a whole state update out to the X-Touch, so that's good enough for me.

Ruby being slow? Never...

Anyway, Ruby was turning out to be a bit too slow for our uses. I'd used Rust before in the past but got frustrated at its seemingly zero trust in us as developers. Coming back to it though, it seems that that's a good thing, so I'm learning to get along with it! Spinning up a socket that could spit data at the X-Touch was easy enough, so I got to translating what I'd written for Ruby into Rust. Everything was going great until actual formatted packets entered the scene.

Rust likes to know up front what every data structure will look like. When you're trying to make a generic packet that could contain any length of data, it gives you funny looks and refuses to play ball. The first solution to this is convert everything incoming into its hex representation, and throw that String around. That seemed to work pretty well, so I stuck with it.

There were four types of packet I wanted to deal with: state updates coming from VoiceMeeter, pushing those updates out to the X-Touch, receiving hardware changes from the X-Touch, and pushing those changes back into VoiceMeeter. First up, state changes coming in.

VoiceMeeter state updates

Once you've subscribed to VoiceMeeter state updates, it'll rapid-fire packets at you for as long as you asked it to. These packets contain everything about the current state: labels, gains, levels, and button states. The RT Packet, (Rust representation below), would come in from the socket and get checked for validity and VBAN subprotocol. If it was Service, we stuck the packet into a 'state updates' thread to be dealt with and pushed out to the surface later.

#[derive(PackedStruct, Debug, Clone, Copy, PartialEq)]
#[packed_struct(endian="lsb", bit_numbering="msb0")]
pub struct RTPacket {
    #[packed_field(element_size_bytes="28")]
    pub header: VBANServiceHeader,

    // packet body
    pub voicemeeter_type: u8,
    pub reserved: u8,
    pub buffer_size: u16,
    voicemeeter_version_raw: [u8; 4],
    pub options: u32,
    pub sample_rate: u32,
    input_levels_raw: [u16; 34],
    output_levels_raw: [u16; 64],
    pub transport: u32,
    pub strip_state: [u32; 8],
    pub bus_state: [u32; 8],
    strip_gain_layer_1_raw: [i16; 8],
    strip_gain_layer_2_raw: [i16; 8],
    strip_gain_layer_3_raw: [i16; 8],
    strip_gain_layer_4_raw: [i16; 8],
    strip_gain_layer_5_raw: [i16; 8],
    strip_gain_layer_6_raw: [i16; 8],
    strip_gain_layer_7_raw: [i16; 8],
    strip_gain_layer_8_raw: [i16; 8],
    bus_gain_raw: [i16; 8],
    strip_labels_raw: [u8; 480],
    bus_labels_raw: [u8; 480],
}

X-Touch surface updates

After the VoiceMeeter status updates have been parsed and processed, we need to push those changes out to the X-Touch. We can concatenate all the button and fader changes into one packet and the display changes to a second and push them out. To save from overloading the board, sending an update approximately every 50ms seems to work. This brought a new challenge though, we can't just push every VBAN state update straight to the board, it'd fall over in seconds.

We need some way to hold the current state of the world. This came in the form of a chunky struct, but had the added benefit of being able to populate multiple of these structs. Why would this be useful, you ask? Layers. We'll get to that in a mo.

X-Touch control changes

The VBAN protocol also has a section in it for tunnelling MIDI messages. VoiceMeeter itself can also listen to this virtual MIDI channel for messages coming in from across the network. You see where this is going right? We can wrap any message that looks MIDI-ish into a VBAN packet and send it on it's way.

This gets better though, because we can intercept two particular MIDI note commands to do something else with. Notes 46 and 47 are the 'Fader Bank' buttons on the board. Listening for these and updating a simple 'page' index in our program, means we can handle multiple board states at once, 'rendering' one of these many states out to the board at any time. And just like that, we can have virtual pages of controls coming out of VoiceMeeter and onto the physical board.

VoiceMeeter state pushes

Wrapping the X-Touch control changes in VBAN packets is all well and good, but shipping them off as-is to VoiceMeeter doesn't help very much because VoiceMeeter doesn't know or care about what page we were on when a control change came in. A bit of MIDI transposition magic using that current page index though, and we end up with each page having a distinct MIDI channel, allowing them to be uniquely bound to virtual controls in VBAN. At the moment we're limited to 2 pages because the faders come in as pitch bends, and there's only 16 channels available to a MIDI pipe.

Processing updates and pulling it all together

The nature of UDP sockets is that you have no idea when a packet will come in, so to receive from them, you have to either block the current thread until something shows up, or constantly poll for data. The former is much better for resources, so that's what I went with. This comes with the slight challenge of shifting data between multiple threads safely. Queues are the standard way to do this, but Rust doesn't appear to have a default thread-safe implementation. After a light bit of thievery from NoraCodes, we can shove data between multiple threads.

I set up four initial threads, one each for input and output against XCtrl and VBAN. Another pair of 'processor' threads for each style of protocol, then a final 'heartbeat' for VoiceMeeter leaves us with 7 threads to keep everything ticking away happily. The main thread is responsible for maintaining the virtual X-Touch state, pulling in every change coming from VoiceMeeter and pushing out an update to the board regularly.

That just about wraps it up! This transformer has been running happily for a few days as a Proxmox guest, and the board has been in regular use making everything so much easier to play with.

Full code can be found in the GitHub repository: https://github.com/ollie-nye/vban-xctrl