DIY Expansion Card for Behringer X32

Based on my Audioplayer-project I developed a DIY card for the Behringer X32. In addition to the youtube-video here you will find some more detailed information about this project and the implementation.

Definition of reverse-engineering by Elliot J. Chikofsky: "Reverse-engineering is the process of analyzing a subject system to identify the system’s components and their interrelationships and create representations of the system in another form or at a higher level of abstraction". This in mind I did some reverse-engineering on one of the most selled audiomixing-consoles in the world.

The Behringer X32 (including the newer products like the WING) is a very versatile eco-system with lots of products able to be interconnected by digital connections like UltraNet, AES50, USB, Ethernet, etc. So I pondered if I could connect my FPGA-based Audioplayer with mixing-abilities to this console in some way. One of the most promising connections seemed to be the expansion-card-connector. In the end this project has developed into an eldorado for implementation of different protocols and signaling technologies...

Hardware-connection

enter image description here

The expansion-card-connector is a TX25-40P-LT-H1E connector made by JAE. It has 40 pins and it took me a while to identify the individual pins, but the general pinout is shown in the following table:

Row A Row B Row C Row D
1 GND 2 GND 21 GND 22 GND
3 GND 4 GND 23 TDM8 X32 Ch 1-8 24 n/c
5 n/c 6 n/c 25 TDM8 X32 Ch 9-16 26 BitClock
7 n/c 8 n/c 27 TDM8 X32 Ch 17-24 28 WordClock
9 n/c 10 GND 29 TDM8 X32 Ch 25-32 30 n/c
11 n/c 12 GND 31 TDM8 SDCard Ch 1-8 32 n/c
13 +5V 14 +5V 33 TDM8 SDCard Ch 9-16 34 MIDI Tx X32
15 +5V 16 +5V 35 TDM8 SDCard Ch 17-24 36 MIDI Tx X32
17 GND 18 GND 37 TDM8 SDCard Ch 25-32 38 UART Rx X32
19 GND 20 GND 39 3V3 Card Ready 40 UART Tx X32 |S

As the X32 and the expansion-cards are using 3V3-signals, the connection to the Vidor4000 is pretty straight-forward. Simply connect the signals directly to the FPGA-pins is OK. On some signals I put some 330Ohm resistors as in the original X-LIVE card to limit the currents and damp oscillations.

Communication-protocols

Once the X32 has intialized the card, the four TDM-lines, bitclock- and wordclock-signals are driven by the main-console with useful signals. So first I had to implement the general communication between the expansion-card and the X32. As plain UART with 38400 baud is used, I could use a standard-communication-port of my microcontroller. The X32 sends and expects commands starting with a "*" and ending with an "#", so my receiver-function puts all received characters into a ringbuffer and searches for a new command when receiving a "#":


  if (SerialX32.available() > 0) {
    // read all characters. The individual commands can be distributed over several chunks.
    uint8_t rxChar = SerialX32.read();
    x32RingBuffer[x32RingBufferPointer++] = rxChar;

    if (x32RingBufferPointer >= X32_RINGBUF_LEN) {
      x32RingBufferPointer = 0;
    }

    // check new command on receiving "#"
    if (rxChar == 35) {
      x32SearchCmd();
    }
  }

Now that I could receive all characters into a ring-buffer, I had to write a function to search for a valid command:


// search for a valid command starting with "*" and ending with "#" and execute the command
void x32SearchCmd() {
  [...]
  // increment i beyond the limit of the buffer to catch commands around the border
  for (i=0; i<(X32_RINGBUF_LEN + 22); i++) {
    if (x32RingBuffer[x32RingBufferPointerOverflow(i)] == 42) { // search for "*"
      // we found the begin of a command
      commandBeginIndex = x32RingBufferPointerOverflow(i);

      // now search for the end of the command from the current index
      for (j=commandBeginIndex; j<(X32_RINGBUF_LEN + 22); j++) {
        if (x32RingBuffer[x32RingBufferPointerOverflow(j)] == 35) {
          // we found a possible end of command. Lets copy the data and validate the command
          commandEndIndex = x32RingBufferPointerOverflow(j);

          if (commandBeginIndex < commandEndIndex) {
            // we are in the same direction -> just copy chars
            // ____*xxxxxx#_____
            commandLength = commandEndIndex - commandBeginIndex + 1;
            memcpy(&commandArray[0], &x32RingBuffer[commandBeginIndex], commandLength);
          }else if (commandBeginIndex > commandEndIndex) {
            // we have to copy the chars around the corner
            // xxx#_________*xxx
            commandLength = commandEndIndex + (X32_RINGBUF_LEN - commandBeginIndex) + 1;
            memcpy(&commandArray[0], &x32RingBuffer[commandBeginIndex], X32_RINGBUF_LEN - commandBeginIndex); // copy first part from the end
            memcpy(&commandArray[X32_RINGBUF_LEN - commandBeginIndex], &x32RingBuffer[0], commandEndIndex + 1); // copy last part from the beginning
          }

          // convert to String, execute the command and output the Answer
          SerialX32.print(x32ExecCmd(String((char*)commandArray)));

          // set used chars from ring-buffer to 0
          for (k=i; k<(i+commandLength); k++) {
            x32RingBuffer[x32RingBufferPointerOverflow(k)] = 0;
          }
          // clear commandArray
          for (k=0; k<32; k++) {
            commandArray[k] = 0;
          }

          break;
        }
      }
    }
  }
}

The X-Card-commands itself are a bit cryptic: to select a specific session from SD-card on the X-LIVE, the command is *9Bxxxxxx#, where the x marks the session-identification. To play the selected session we have to read the command ***9D#**. The most important command we will receive from the main-console is ***8I#**. This requests for identification of the card. We have to answer with ***8X-UREC:A:12#** if we want to be an X-LIVE-card with firmware-revision A12. I put all commands I found up to now into an unofficial command-documentation at GitHub.

Audiodata

Now that the X32 recognized us as a valid X-Card, I had to implement the receiver for the 8-channel TDM-audio. I used my I2S-receiver for this task. Here you can see the input- and output-definition of the VHDL-block:


port (
    clk         : in std_logic; -- mainclock
    bclk        : in std_logic; -- bit-clock of TDM signal (for X32 it is 12.288 MHz)
    fsync       : in std_logic; -- Frame sync (for X32 it is 384 kHz)
    sdata       : in std_logic; -- serial data (8x 32 bit audio-data: 24 bit of audio followed by 8 zero-bits)
    
    ch1_out     : out std_logic_vector(23 downto 0); -- received audio-sample
    ch2_out     : out std_logic_vector(23 downto 0); -- received audio-sample
    ch3_out     : out std_logic_vector(23 downto 0); -- received audio-sample
    ch4_out     : out std_logic_vector(23 downto 0); -- received audio-sample
    ch5_out     : out std_logic_vector(23 downto 0); -- received audio-sample
    ch6_out     : out std_logic_vector(23 downto 0); -- received audio-sample
    ch7_out     : out std_logic_vector(23 downto 0); -- received audio-sample
    ch8_out     : out std_logic_vector(23 downto 0); -- received audio-sample
    sync_out    : out std_logic -- new data received successfully
  );

As we receive 8 24-bit samples with each 48kHz, we have to decode individual bits at a frequency of 12.288MHz as TDM uses 32-bits. So I used a 200MHz clock for the FPGA-logic to have a good enough time-resolution. The VHDL-implementation is then pretty easy: on each read bit, I increment a bitcounter and on the rising edge of the wordclock I put all received data to the output-signals:


if rising_edge(clk) then
    zbclk <= bclk;
    zzbclk <= zbclk;
    zzzbclk <= zzbclk;

    if (zzbclk = '1' and zzzbclk = '0') then
        -- rising edge of bitclk
        zfsync <= fsync;
        
        -- continuously reading bit into shift-register
        if ((bit_cnt >= 0) and (bit_cnt <= 31)) then
            ch1 <= ch1(ch1'high - 1 downto 0) & sdata;
        elsif ((bit_cnt >= 32) and (bit_cnt <= 63)) then
            ch2 <= ch2(ch2'high - 1 downto 0) & sdata;
        elsif ((bit_cnt >= 64) and (bit_cnt <= 95)) then
            ch3 <= ch3(ch3'high - 1 downto 0) & sdata;
        elsif ((bit_cnt >= 96) and (bit_cnt <= 127)) then
            ch4 <= ch4(ch4'high - 1 downto 0) & sdata;
        elsif ((bit_cnt >= 128) and (bit_cnt <= 159)) then
            ch5 <= ch5(ch5'high - 1 downto 0) & sdata;
        elsif ((bit_cnt >= 160) and (bit_cnt <= 191)) then
            ch6 <= ch6(ch6'high - 1 downto 0) & sdata;
        elsif ((bit_cnt >= 192) and (bit_cnt <= 223)) then
            ch7 <= ch7(ch7'high - 1 downto 0) & sdata;
        elsif ((bit_cnt >= 224) and (bit_cnt <= 255)) then
            ch8 <= ch8(ch8'high - 1 downto 0) & sdata;
        end if;

        -- check for positive edge of frame-sync (1 bit-clock before bit 0 of channel 1)
        if (fsync = '1' and zfsync = '0') then
            -- rising edge of sync-signal
            ch1_out <= ch1(31 downto 8);
            ch2_out <= ch2(31 downto 8);
            ch3_out <= ch3(31 downto 8);
            ch4_out <= ch4(31 downto 8);
            ch5_out <= ch5(31 downto 8);
            ch6_out <= ch6(31 downto 8);
            ch7_out <= ch7(31 downto 8);
            ch8_out <= ch8(30 downto 7); -- in this step we are missing a single bit
            sync_out <= '1';

            bit_cnt <= 0;
        else
            bit_cnt <= bit_cnt + 1;
        end if;
        
        if (bit_cnt = 4) then
            -- reset sync_out
            sync_out <= '0';
        end if;
    end if;
end if;

The VHDL-code for sending 8-channel audio-data back to the X32 is using a very comparable logic. The whole code can be found in the GitHub-project.

Controls

As I wanted to use this extension-card for some of my gigs on stage, I tried to implement some controls in addition to the WiFi-webinterface. The first idea was to add some ethernet-controls using another self-developed app. But while looking at the ethernet-protocol for the original X32-Edit I decided to support this XRemote-protocol as it is based on the OSC-protocol. Once I figured out how to initiate the communication and the X32-Edit-software displayed my card as a valid endpoint, most of the work was comparing and interprating ASCII-strings. The "address" of the data is sent in plain ASCII, while the value is sent as 32-bit integer- or float-value:


// /ch/xx/mix/fader~~~~,f~~[float]
// /ch/xx/mix/pan~~,f~~[float]
// /ch/xx/mix/on~~~,i~~[int]
channel = ((rxData[4]-48)*10 + (rxData[5]-48)) - 1;
if (len > 13) {
if ((rxData[7] == 'm') && (rxData[8] == 'i') && (rxData[9] == 'x')) {
  if ((rxData[11] == 'f') && (rxData[12] == 'a') && (rxData[13] == 'd')) {
    // get fader-value
    value32bit.u8[0] = rxData[27];
    value32bit.u8[1] = rxData[26];
    value32bit.u8[2] = rxData[25];
    value32bit.u8[3] = rxData[24];

    float newVolume = (value32bit.f * 54.0f) - 48.0f;
    mixerSetVolume(channel, newVolume);

Now that the XRemote was working I had a look at the XTouch-controller. As I said, this project has developed into an eldorado for implementation of different protocols. Different to the XRemote-protocol, the XTouch-device uses up to 300-byte-long UDP-commands over ethernet. But some of the commands are really simple: to update the main-fader-position, for instance, I had to send a simple 3-byte-command as the communication is leaning on a general MIDI-communication:


uint8_t XCtl_TxMessage[3];
uint16_t faderPosition;
XCtl_TxMessage[0] = 0xE8; // E8=Masterfader
XCtl_TxMessage[1] = faderPosition & 0x7F; // MIDI-Values between 0 and 127
XCtl_TxMessage[2] = (faderPosition >> 7) & 0x7F;

if (XCtlUdp.beginPacket(XCtl.ip, 10111)) {
  XCtlUdp.write(XCtl_TxMessage, 3);
  XCtlUdp.endPacket();
}

After several days I figured out how to implement all faders, the meters, knobs and LC-displays of the device. In the current state of this projects up to 4 XTouch-devices are supported at the same time.

As all XTouch-functions were working as expected I realized, that the MackieMCU-protocol is very comparable to the XTouch-protocol. As one of the youtube-commenter suggested to support the DAW-option of the X32, this was the right time to implement this MackieMCU-protocol as I already connected the MIDI-Rx/Tx to the Vidor 4000. After a couple of hours the X32 showed text from my expansion-card: enter image description here

As I had plenty of space left in the SAMD21 controller, I implemented some DMX512-controls next to the audio-mixing-functions. So it is possible to control the stage-lights from the X32-surface simply by pressing the "REC" button to switch between audio and DMX512.

In the end a lot of different control-schemes have been implemented for this DIY card:

  • a WiFi webinterface (based on the ESP32)
  • Ethernet-control using plain ASCII-commands
  • control via the X32-surface using MackieMCU emulation
  • Ethernet-control using Behringers X32-Edit or the Android-App (XRemote)
  • Ethernet-control using XTouch-control

Test of the card

In the last days of 2024 I've developed a card to connect two Expansion-Cards. I added 32 individual RC-LowPass-Filters on this card to be used as a Digital-2-Analog-Converter as well. So I used this card to initialize an X-LIVE-Card to output 32 analog-signals to my main-console that were routed to my DIY-Expansion-Card. Together with the 32 audio-signals in the original processing this resulted in a total of 64 audio-channels processed by the X32. You can find the software and PCB information on GitHub as well:

GitHub-Repository of DuoXCardAdapter

enter image description here

The results of the card-tests can be found on Youtube:

enter image description here

Summary

As you may have realized, I love projects that implements a lot of different functions, that are a combination of hard- and software. This DIY expansion-card was a perfect example for this: initially started as a proof-o-concept, this card developed into a versatile and universal control option with additional audio functions. enter image description here

It was lot of fun programming this piece of hardware. As this card is designed for a hardware initially published in 2012, I'm now working on an expansion-card for the Behringer WING, but this is a topic for another blog-post.

Comments