GOOROO LIOBOX2 Integration

This script works with AbleSet 3.1.0-beta.13 or later and allows you to use a LIOBOX2 to control AbleSet. It’s split into two parts: the project script which sends your setlist and playback/loop status to the controller, and a MIDI mapping script which parses incoming MIDI to allow you to control AbleSet with the controller.

This is not an official integration and not endorsed by GOOROO, but I thought it would be interesting to see how well I could make it work with AbleSet.

To set this up, make sure that the LIOBOX plugin is disabled in Live’s MIDI settings and your LIOBOX is set to the “Ableton - All Songs” mode, then add these scripts:

Project Script

/* This script sends data to your LIOBOX */

const COMMANDS = {
  /** This is needed for the spare port to work */
  spare: 0x03,
  /** Sets the loop state */
  loop: 0x34,
  /** Sets the mode (session/arrangement) */
  mode: 0x42,
  /** Sends a ping containing the current song index and playback state */
  ping: 0x43,
  /** Makes the LioBox respond with its device ID */
  identify: 0x44,
  /** Sends the number of songs */
  setlistLength: 0x46,
  /** Sends the information for a song */
  songInfo: 0x47,
};

/** Used as a separator between values */
const GAP = 0x03;

/** This value indicates that we're in arrangement mode */
const ARRANGEMENT_MODE = 0x32;

/**
 * This is sent at the beginning of each message
 * @param command {number}
 */
function makeHeader(command) {
  return [0x00, 0x22, 0x1d, 0x10, 0x00, 0x00, command];
}

/**
 * Sends a SysEx message to the LioBox based on its port.
 * In my tests, the LioBox's MASTER port uses ID [0x31, 0x38],
 * and the SPARE port uses ID [0x32, 0x30].
 *
 * @param command {number}
 * @param data {number[]}
 * @param portOverride {string | null}
 */
function sendCommand(command, data = [], portOverride = null) {
  const port = portOverride ?? shared("lioBoxPort");

  if (port) {
    const header = makeHeader(command);
    const id = port.includes("MASTER") ? [0x31, 0x38] : [0x32, 0x30];
    sendMidiSysex(port, [...header, ...id, GAP, ...data]);
  }
}

const updateSongs = debounce(() => {
  const songs = osc("/setlist/songs", "all");
  const descriptions = osc("/setlist/songDescriptions", "all");
  const durations = osc("/setlist/songDurations", "all");
  const tempos = osc("/setlist/songTempos", "all");
  log("Updating songs...");

  sendCommand(COMMANDS.spare, [0x31, GAP]);
  sendCommand(COMMANDS.mode, [ARRANGEMENT_MODE, GAP]);
  sendCommand(COMMANDS.setlistLength, [
    ARRANGEMENT_MODE,
    GAP,
    ...makeAscii(String(songs.length)),
    GAP,
  ]);

  for (const [index, song] of songs.entries()) {
    const name = song?.slice(0, 22) ?? "";
    const description = descriptions[index]?.slice(0, 5) ?? "";
    const duration = String(Math.round(durations[index] ?? 0));
    const tempo = String(tempos[index] ?? "");

    sendCommand(COMMANDS.songInfo, [
      ARRANGEMENT_MODE,
      GAP,
      ...makeAscii(String(index)),
      GAP,
      ...makeAscii(name),
      GAP,
      ...makeAscii(description),
      GAP,
      ...makeAscii(duration),
      GAP,
      ...makeAscii(tempo),
      GAP,
    ]);
  }

  sendPing();
}, 10);

updateSongs();
onOscChange("/setlist/songs", updateSongs);
onOscChange("/setlist/songDescriptions", updateSongs);
onOscChange("/setlist/songDurations", updateSongs);
onOscChange("/setlist/songTempos", updateSongs);
onOscChange("/shared/lioBoxLastRefresh", updateSongs);

function sendPing() {
  const isPlaying = osc("/global/isPlaying");
  const songIndex = osc("/setlist/activeSongIndex") ?? 0;
  sendCommand(COMMANDS.ping, [
    isPlaying ? 0x32 : 0x30,
    GAP,
    ...makeAscii(String(songIndex)),
    GAP,
  ]);
}

onOscChange("/global/isPlaying", sendPing);
onOscChange("/setlist/activeSongIndex", sendPing);
setInterval(sendPing, 1000);

onOscChange(
  "/setlist/isInActiveSectionLoop",
  ([isLooped]) => {
    sendCommand(COMMANDS.loop, [isLooped ? 0x31 : 0x30, GAP]);
  },
  true,
);

onOscChange(
  "/midi/outputs",
  (outputs) => {
    for (const output of outputs) {
      if (output?.includes("LioBox")) {
        sendCommand(COMMANDS.identify, [], output);
      }
    }
  },
  true,
);

MIDI Mapping

You can add this script to the “LioBox MASTER Port 1” input. If you’re using a redundant setup, with the controller connected to both computers, you also need to add it to the “LioBox SPARE Port 1” input. Make sure that the AbleNet toggle is disabled for both inputs.

/* This script processes incoming MIDI data from your LIOBOX */

if (
  midi.raw[0] === 0xf0 &&
  midi.raw[1] === 0x00 &&
  midi.raw[2] === 0x22 &&
  midi.raw[3] === 0x1d &&
  midi.raw[4] === 0x10
) {
  const command = midi.raw[7];

  if (command === 0x50 || command === 0x51) {
    // Device Identify / Refresh Request
    setShared("lioBoxPort", midi.input);
    setShared("lioBoxLastRefresh", now());
  } else if (command === 0x52) {
    // Play a song
    const rawIndex = midi.raw.slice(11, midi.raw.indexOf(0x03, 11));
    const index = Number(rawIndex.map((c) => String.fromCharCode(c)).join(""));

    log("Playing song", index);
    sendOsc("/setlist/jumpToSong", index + 1);
    sendOsc("/setlist/jumpToQueued", true);
    sendOsc("/global/play");
  } else if (command === 0x54) {
    // Stop playback
    log("Stopping playback");
    sendOsc("/global/stop");
  } else if (command === 0x57) {
    // Prev Song
    log("Jumping to Prev Song");
    sendOsc("/setlist/jumpBySongs", -1);
  } else if (command === 0x58) {
    // Next Song
    log("Jumping to Next Song");
    sendOsc("/setlist/jumpBySongs", 1);
  } else if (command === 0x5a || command === 0x5b) {
    // Loop Control
    const type = midi.raw[11];

    if (type === 0x30 || type === 0x33) {
      log("Toggling Loop");
      sendOsc("/loop/toggle");
    } else if (type === 0x31) {
      log("Enabling Loop");
      sendOsc("/loop/enable");
    } else if(type === 0x32) {
      log("Escaping Loop");
      sendOsc("/loop/escape");
    }
  } else {
    const data = midi.raw
      .slice(8)
      .map((c) => c.toString(16).padStart(2, "0"))
      .join(" ");
    log("Unknown command", command.toString(16).padStart(2, "0"), { data });
  }
}

Let me know if you have any feedback or ideas for improvements! :slight_smile:

3 Likes

Do you think it’s work for the Liobox V1 ?

I don’t have one here to check, but feel free to give it a try! :slight_smile:

Awesome, could you give a little more info on where and how to add these scripts?

Hello. We are using a redundant connection of two computers with a LIOBOX connected to the USB host input on the front panel of the PlayAUDIO1U. Please advise how to set up Ableset so that the LIOBOX works with this connection, if it is even possible. Thank you very much

Hey @Gorgy, welcome to the forum!

When you use the PlayAUDIO1U to send MIDI to both computers, you only need to connect your LIOBOX’s USB A port to it via USB.

The script currently looks for MIDI ports that include the word “LioBox MASTER”, so you could rename the USB port your LIOBOX is connected to to “LioBox MASTER” in the Auracle X app:

Then, disconnect the PlayAUDIO1U from both computers, open Audio MIDI Setup, and delete the interface. After that, reconnect it, and it should appear with the new port name:

You can then add the MIDI Mapping script to that port in AbleSet by clicking the three-dot button next to “Add Mapping” and selecting “Script”, then pasting the script from the post:

Make sure that the AbleNet toggle is disabled. It’s not needed as both computers receive the same MIDI messages anyway.

Then, add the project script. You can find the script editor by going to the settings page and clicking on “Project Script” under “MIDI Mapping, OSC & Scripting”:

Click the “Save & Run Project Script” button and you should be good to go!

If the connection isn’t established right away, you might need to go into the LIOBOX’s menu and select “REFRESH”.

Let mew know if this works for you :slight_smile:

1 Like

Amazing! Thank you very much!

The section markers now only works when naming them. I normally just add a locator at the beginning of a song and at the end of a song. Then select “Place Locatos on Section Clips”. But the Liobox doesn’t seem to recognized the section parts. Here an attempt:

/* This script sends data to your LIOBOX */
/* Row 1 = song title (fixed), row 2 = next section (dynamic), full list below for navigation */

const COMMANDS = {
  spare: 0x03,
  loop: 0x34,
  mode: 0x42,
  ping: 0x43,
  identify: 0x44,
  setlistLength: 0x46,
  songInfo: 0x47,
};

const GAP = 0x03;
const ARRANGEMENT_MODE = 0x32;

function makeHeader(command) {
  return [0x00, 0x22, 0x1d, 0x10, 0x00, 0x00, command];
}

function sendCommand(command, data = [], portOverride = null) {
  const port = portOverride ?? shared("lioBoxPort");
  if (port) {
    const header = makeHeader(command);
    const id = port.includes("MASTER") ? [0x31, 0x38] : [0x32, 0x30];
    sendMidiSysex(port, [...header, ...id, GAP, ...data]);
  }
}

const updateList = debounce(() => {
  const songs = osc("/setlist/songs", "all");
  const descriptions = osc("/setlist/songDescriptions", "all");
  const durations = osc("/setlist/songDurations", "all");
  const tempos = osc("/setlist/songTempos", "all");
  const activeSongIndex = osc("/setlist/activeSongIndex") ?? 0;
  const sections = osc("/setlist/sections", "all") ?? [];
  const activeSectionIndex = osc("/setlist/activeSectionIndex") ?? 0;

  log("Updating list...");

  const flatList = [];
  const songToFlatIndex = {};

  for (let i = 0; i < songs.length; i++) {
    songToFlatIndex[i] = flatList.length;
    flatList.push({ type: "song", songIndex: i });

    if (i === activeSongIndex && sections.length > 0) {
      // Dynamic next section entry right below song title (becomes row 2)
      const nextSectionIndex = activeSectionIndex + 1;
      if (nextSectionIndex < sections.length) {
        flatList.push({
          type: "nextSection",
          songIndex: i,
          sectionIndex: nextSectionIndex,
        });
      }

      // All sections for scrolling and navigation
      for (let j = 0; j < sections.length; j++) {
        flatList.push({ type: "section", songIndex: i, sectionIndex: j });
      }
    }
  }

  setShared("lioBoxIndexMap", JSON.stringify(flatList));
  setShared("lioBoxSongToFlat", JSON.stringify(songToFlatIndex));

  sendCommand(COMMANDS.spare, [0x31, GAP]);
  sendCommand(COMMANDS.mode, [ARRANGEMENT_MODE, GAP]);
  sendCommand(COMMANDS.setlistLength, [
    ARRANGEMENT_MODE, GAP, ...makeAscii(String(flatList.length)), GAP,
  ]);

  for (const [flatIndex, entry] of flatList.entries()) {
    let name, description, duration, tempo;

    if (entry.type === "song") {
      const si = entry.songIndex;
      name = (songs[si] ?? "").slice(0, 22);
      description = (descriptions?.[si] ?? "").slice(0, 5);
      duration = String(Math.round(durations?.[si] ?? 0));
      tempo = String(tempos?.[si] ?? "");
    } else if (entry.type === "nextSection") {
      // ">>" prefix so it's duidelijk onderscheiden van de gewone secties
      name = (">> " + (sections[entry.sectionIndex] ?? "")).slice(0, 22);
      description = "";
      duration = "";
      tempo = "";
    } else {
      name = ("> " + (sections[entry.sectionIndex] ?? "")).slice(0, 22);
      description = "";
      duration = "";
      tempo = "";
    }

    sendCommand(COMMANDS.songInfo, [
      ARRANGEMENT_MODE, GAP,
      ...makeAscii(String(flatIndex)), GAP,
      ...makeAscii(name), GAP,
      ...makeAscii(description), GAP,
      ...makeAscii(duration), GAP,
      ...makeAscii(tempo), GAP,
    ]);
  }

  sendPing();
}, 10);

updateList();
onOscChange("/setlist/songs", updateList);
onOscChange("/setlist/songDescriptions", updateList);
onOscChange("/setlist/songDurations", updateList);
onOscChange("/setlist/songTempos", updateList);
onOscChange("/setlist/sections", updateList);
onOscChange("/setlist/activeSongIndex", updateList);
onOscChange("/setlist/activeSectionIndex", updateList);
onOscChange("/shared/lioBoxLastRefresh", updateList);

function sendPing() {
  const isPlaying = osc("/global/isPlaying");
  const songIndex = osc("/setlist/activeSongIndex") ?? 0;

  // Ping always stays on the song title
  const songToFlatJson = shared("lioBoxSongToFlat");
  const songToFlat = songToFlatJson ? JSON.parse(songToFlatJson) : {};
  const flatIndex = songToFlat[String(songIndex)] ?? songIndex;

  sendCommand(COMMANDS.ping, [
    isPlaying ? 0x32 : 0x30,
    GAP,
    ...makeAscii(String(flatIndex)),
    GAP,
  ]);
}

onOscChange("/global/isPlaying", sendPing);
setInterval(sendPing, 1000);

onOscChange(
  "/setlist/isInActiveSectionLoop",
  ([isLooped]) => {
    sendCommand(COMMANDS.loop, [isLooped ? 0x31 : 0x30, GAP]);
  },
  true,
);

onOscChange(
  "/midi/outputs",
  (outputs) => {
    for (const output of outputs) {
      if (output?.includes("LioBox")) {
        sendCommand(COMMANDS.identify, [], output);
      }
    }
  },
  true,
);

Hi Leo! I tested it today and it works! Both machines can be controlled using Liobox. It’s ideal for our setup, we have Liobox by the drum machine and both redundant computers are in the stagebox. I have one last question. How do I arrange in this setup that the MASTER computer can also control the SPARE computer when our tech needs to play a song from our stagebox? Is it possible to avoid AbleNet? So that both computers and Liobox communicate only based on which song/is playing/stopping/next etc.?

1 Like

Hey @Gorgy, I’m glad to hear this script is working well for you!

Regarding your question, could you elaborate on what you mean exactly? If you’d like to control both computers from another device (e.g. iPad / phone) then you’d need to enable AbleNet so it can control both simultaneously.

I’m looking forward to your reply! :slight_smile:

I thought that Liobox + Master Computer controls and Spare only listens. Maybe Spare somehow listens to some of the MIDI commands that Master sends to Liobox. Somehow using MIDI routing in PlayAudio1U. Is that possible?

I don’t know where the error was, but last time it wasn’t completely reliable with AbleNet. It messed up the playback and the tracks. So I would like a simple and bulletproof solution so that our technician doesn’t have to do complicated settings. Just open the Project and we’re good to go.

Hi @leolabs
I found where the error was. I had Drift correction checked on both machines. So the sampler tracks were shifted against the click track. I tried it without it today and it works fine. Sorry, I’m new to Ableton after many years of using Logic Pro. But I love its customizability combined with Ableset!

Hello Léo, i have this error on your Project Script :
Property ‘slice’ does not exist on type ‘never’.(2339)

Here in the code :

for (const [index, song] of songs.entries()) {

const name = song?.slice(0, 22) ?? "";

const description = descriptions[index]?.slice(0, 5) ?? “”;

const duration = String(Math.round(durations[index] ?? 0));

const tempo = String(tempos[index] ?? “”);

Any ideas ? @leolabs