QLC+ Cues

I heard from Nico J (Cappiz on the Zulip stream) with an interesting question about triggering cues in the QLC+ open-source lighting control software. While this could be done by MIDI, it would be nice to do it in a way that also allowed the same virtual button to be controlled by a physical MIDI controller, and to provide feedback on that controller to make it visible when Beat Link Trigger is activating cues.

Using Show Cues

The first way we got that working is by having BLT Show Cue expressions run a shell script that talked to the QLC+ API. That worked well enough that I plan to write up another integration example about how to run shell scripts in general. But since the script talked to the QLC+ API over a web socket, we wanted to reduce latency and complexity by having BLT talk directly to the web socket. And because this won’t be the last time we want to talk to something using web sockets, I decided to embed a web socket client into BLT to make it even easier.

Global Setup Expression

With that done, the first step is to create an HTTP client we can use to manage web socket connections. We will store it in the show globals under the key :http, by adding the following line to the Global Setup Expression:

(swap! globals assoc :http (http/create-client))

At first we also opened the web socket connection in Global Setup, but I quickly realized this could cause problems if QLC+ was not already up with its web API running when the show opened: the attempt to open the web socket would fail, and none of the cues would work until the show was closed and reopened after getting QLC+ running in the right state.

To have QLC+ start its web API, you need to run it with the -w or --web option.

Shared Functions

So I built a more robust approach, with the help of some new Shared Functions:

(defn find-qlc-web-socket  (1)
  "Checks to see if there is already an open QLC+ web socket; if so,
  returns it. Otherwise, tries to create one, logging an error and
  returning `nil` if it fails."
  [globals]  (2)
  (let [ws (:qlc-ws @globals)]  (3)
    (or ws
        (try  ; The web socket isn't already open, try creating it.
          (let [ws (http/websocket
                    (:http @globals) "ws://"  (4)
                    :close (fn [_ws code reason]  (5)
                             (timbre/info "QLC+ web socket closed, code" code
                                          "reason" reason)
                             (swap! globals dissoc :qlc-ws))
                    :error (fn [_ws error]
                             (timbre/error "QLC+ web socket error:" error))
                    :text (fn [_ws message]
                            (timbre/info "QLC+ web socket received message:"
            (swap! globals assoc :qlc-ws ws)  (6)
          (catch Exception _e
            (timbre/error "Unable to open web socket connection to QLC+"))))))

(defn send-qlc-message  (7)
  "Sends a web socket message to QLC+, opening the web socket connection
  if it isn't already. Needs to be given the globals, so it can look
  up the connection, or use the async http client to create it if
  [globals message]
  (when-let [ws (find-qlc-web-socket globals)]
    (http/send ws :text message)))  (8)
1 This function is used whenever we need to use the web socket to talk to QLC+.
2 We need to pass globals to the function because, unlike a single-purpose expression where BLT can “magically” make this value available, shared functions are called from many different contexts, so we need to explicitly pass them any values they need to work with.
3 We first check if there is already an open QLC+ web socket recorded in the globals. If so, we simply return it. Otherwise we proceed to open a new one.
4 This is the URL to talk to the QLC+ web socket on the same machine that BLT is running on. You would change the IP address from (localhost) to the actual address of a different machine if you wanted to talk to a remote instance of QLC+.
5 This callback function is called whenever the web socket closes (including unexpectedly because QLC+ has quit), so we remove it from the globals and will know we need to try opening a new connection next time. The other two callbacks are called when there is an error with the socket, or we receive messages from QLC+. For the moment, we simply log them. We would do something fancier in the :text handler if we wanted to process responses.
6 We record the new web socket connection in the globals so we can find it and return it next time, and then return it.
7 This is the function we call when we want to send a message to QLC+. It takes globals so it can pass it along to find-qlc-web-socket, which does the work of finding or creating the web socket, as needed.
8 With all the hard work delegated, the actual sending of the message is simple once we have a web socket to use.

Although this last shared function is not strictly necessary, it makes the cue code more readable by setting up the message format needed to tell QLC+ to set a widget value:

(defn set-qlc-widget-value
  "Formats and sends a message to QLC+ telling it to set a specific
  virtual console widget to a particular value. If the widget is a
  button and the value is 255, QLC+ will act like that button has
  been pressed."
  [globals widget-id value]
  (send-qlc-message globals (str widget-id "|" value)))

This means that if a cue wants to tell QLC+ to simulate a button press on a virtual console button whose widget ID is 7, it can use code like this:

(set-qlc-widget-value globals 7 255)

We will probably want to set up functions like that for any of the kinds of messages we end up wanting to send to QLC+.

Global Shutdown Expression

To clean up after ourselves, we want to close the web socket if it is open, and then the HTTP client, in the Global Shutdown Expression. We can do that by adding these lines:

(when-let [ws (:qlc-ws @globals)]
  (.close ws))
(.close (:http @globals))

Doing Without Show Cues

With this in place, Nico J was able to create track cues that used set-qlc-widget-value to trigger QLC+ lighting cues quickly and efficiently. But he wanted to be able to set those cues up directly in rekordbox, the way Netsky had done for MIDI. So we proceeded to build a variation on that approach.

To work that way, move the Global Setup Expression lines, Shared Functions, and Global Shutdown Expression lines out of the Show file where you have been experimenting with them (if you have), and instead put them in the Beat Link Triggers window, because we will be using a global trigger instead of a show. The code above is still correct, it just needs to be moved to the Triggers window before proceeding with this new approach. Then add the new code shown below.

To save all the effort of typing in the code, you can start by downloading the configuration file I created for this exmaple and opening that within Beat Link Trigger. That will set up a single trigger called “Cue-driven QLC+ Button Presser” that watches the current Master player.

If you already have triggers of your own that you want to keep, be sure to save your configuration before opening another one! In that case you may want to export your triggers, or manually copy and paste the relevant pieces of code into your Shared Functions and Expressions.

Cue-driven QLC trigger


Once you have the functions, expressions, and trigger set up, using it is pretty simple! Within rekordbox, create a memory point or hot cue at the beat where you want a QLC+ virtual console button to be pressed, and then put the string QLC: followed by the widget ID number of the button you want to be pressed when that beat is reached. For example, a hot cue with QLC:5 somewhere in its name would “press” the virtual console button with widget ID 5 when that beat is reached. The memory point or hot cue needs to be on a beat for this to work.

If you already have a hot cue or memory point at the beat where you want the button pressed, you can use it instead of creating another one there: just add the QLC button press request to its name.

If you want more than one button to be pressed when that beat is reached, you can list multiple numbers separated by commas (but no spaces or other characters), like QLC:5,42,100 which would press buttons 5, 42, and 100 when reached.

Tracks with QLC cues

If you don’t want to only respond to cues being played on the Master player, you can change the player being watched by the trigger using the Watch menu, and you can use the trigger’s gear or context menu to export it to a file, then create new triggers and import that file into them, so you can have multiple copies watching different players.

New Shared Functions

How does this all work? And if you don’t want to blow away your configuration by loading the one linked above, how do you add these features to your existing configuration?

This first set of expressions are configured using the Triggers menu at the top of the window. (These are in addition to the shared functions that were shown above.)

The first bit of magic happens by registering some code to watch for tracks to be loaded, and look for the special QLC widget markers in their cue lists. This is supported by a set of shared functions.

If you loaded the configuration file, you don’t need to type these, but may want to read them to learn more about how to create integrations like this. If you are keeping your existing configuration and want to add these new features, then copy and paste these at the end of your Triggers window Shared Functions:

(defonce  (1)
 ^{:doc "Holds a map from player number to a map of cue times for that player.
  The cue time maps are indexed by track position (in milliseconds),
  and their values are sets of QLC+ button IDs to press when we reach
  a beat that is within 50 milliseconds of that time. This map is
  built by `find-qlc-cues` below whenever the track metadata for a
  player changes."}
  qlc-cue-times (atom {}))

(defn find-qlc-cues  (2)
  "Scans all the cues and loops found in the supplied track metadata
  looking for any that contain the string QLC: followed immediately
  by a number. Returns a map whose keys are the track time at which
  each such cue or loop begins, and whose values are sets of the
  number that was found in the cue name(s) that started at that time.
  If there is no track metadata, or it has no cue list, returns
  [^TrackMetadata md]
  (when md
    (when-let [cue-list (.getCueList md)]
      (reduce (fn [result cue]
                (if-let [[_ ids] (re-find #"QLC:(\d+(,\d+)*)"
                                            (.-comment cue))]
                  ;; Cue name matches.
                  (update result (.-cueTime cue) (fnil clojure.set/union #{})
                          (clojure.string/split ids #","))
              {} (.-entries cue-list)))))

(def qlc-cue-indexer  (3)
  "Responds to the coming and going of track metadata, and updates our
  list of cue-defined beats on which QLC+ button presses need to be sent."
  (reify org.deepsymmetry.beatlink.data.TrackMetadataListener
    (metadataChanged [this md-update]
      (swap! qlc-cue-times assoc (.player md-update)
             (find-qlc-cues (.metadata md-update))))))

(defn send-qlc-cues-near-time  (4)
  "Finds all QLC cues close enough to the specified time for the
  specified device and sends the corresponding button press messages
  to the QLC+ web socket, which we can look up through the globals."
  [time device-number globals]
  (doseq [[_ ids] (filter (fn [[cue-time]] (> 50 (Math/abs (- time cue-time))))
                          (get @qlc-cue-times device-number))]
        (doseq [widget-id ids]
          ;; Send presses for each id specified by one of the cues we reached.
          (set-qlc-widget-value globals widget-id 255))))
1 This sets up an atom that will hold an index by player of any cues in that player’s track whose name match the QLC: pattern, their position within the track, and the QLC+ widget IDs for which button presses should be sent when the beat at that position is reached.
2 This takes the metadata that describes a track that has been loaded into a player, and scans through all the hot cues, memory points, and loops in that track, looking for the pattern that identifies a request for QLC button presses. It builds the index structure for that track.
3 This creates an object that can be registered with the Beat Link library to update the QLC cue index whenever there is new information about a track loaded in a player by calling the functions above.
4 This is a helper function called by the trigger whenever the track has moved to a new beat. It uses the index to see if it’s supposed to send any QLC+ button presses, and sends them.

New Global Setup

When the configuration file is loaded, this new line arranges for the indexing function to be run as tracks come and go, in addition to what we were doing before:

(.addTrackMetadataListener metadata-finder qlc-cue-indexer)

New Global Shutdown

When the Triggers window is being closed, or a different configuration file is being loaded, this new line unregisters our indexer:

(.removeTrackMetadataListener metadata-finder qlc-cue-indexer)

Trigger Code

The rest of the code goes in the trigger itself. If you didn’t load the configuration file, create a new trigger, type "Cue-driven QLC+ Button Presser" for its comment, set it to Watch the Master Player, set its Enabled filter to Always, and its Message to Custom, as shown above.

You can close the Activation Expression editor that gets opened up without typing anything in there, because this is an unusual trigger that sends messages at times other than when it activates or deactivates. But we still want to have Message set to Custom because we don’t want stray MIDI messages being sent just because the track started or stopped.

The MIDI Output and Channel don’t matter because we are not sending MIDI messages, but the trigger will be disabled if you have chosen an output that is no longer available.

It’s time for the final expressions that tie this all together. These are edited using the trigger’s gear or context menu:

Beat Expression

This is run whenever a beat packet is received from the watched player, so it is a great place to check if it is time to send any QLC+ button presses using the helper function we looked at above:

;; We can only run when the TimeFinder is running.
(when track-time-reached
  ;; Record that this beat has been handled, and
  ;; the Tracked Update expression can ignore it.
  (swap! qlc-cue-times assoc-in [:sent device-number] beat-number)
  ;; Send the MIDI cues, if any, falling on this beat.
  (send-qlc-cues-near-time track-time-reached device-number globals))

If you read the comments in that code, they foreshadowed an issue: One thing that makes running shows based on the Pro DJ Link protocol challenging is that you don’t always get beat messages when you want them. If you are playing through a track and pass over a beat, you’re golden, you get the packet. But if you start the track at a beat, or jump to a hot cue that is at a beat, then sometimes you don’t receive the beat packet, because the track was already a tiny bit past the precise beat moment.

So that is what led to the most tricky code here (and in the Show feature). Here’s how we work around it.

Tracked Update Expression

This is run whenever we get a status update from a player, which happens around five times per second. We can use it to see if we have started playing without getting a beat packet, or if we have jumped to a new beat because of a hot cue or memory point.

;; We can only run when playing and the TimeFinder is running.
(when (and playing? track-time-reached)
  ;; Do nothing if the current beat has already been handled.
  (when (not= beat-number (get-in @qlc-cue-times [:sent device-number]))
    ;; Note this beat's been handled for next time.
    (swap! qlc-cue-times assoc-in [:sent device-number] beat-number)
    ;; Press the QLC+ buttons, if any, for the point where playback began.
    ;; We assume playback began at the start of the current beat.
    (let [grid    (.getLatestBeatGridFor beatgrid-finder device-number)
          started (.getTimeWithinTrack grid beat-number)]
      (send-qlc-cues-near-time started device-number globals))))

Both the Beat Expression and this Tracked Update expression make a special entry in the index atom to report when they have handled a particular beat, so this code doesn’t send that beat’s QLC+ button presses more than once.

If the current beat hasn’t been marked as already handled, this code finds the start time of the current beat, looks up any button presses that should be sent for it, and sends them in the same way the Beat Expression did.

With those two expressions in place, it doesn’t matter how a beat is reached, its button presses (if any) get sent.

Deactivation Expression

One final nice touch: if the DJ stops the track, we want to clear out the notion of what beat was handled, so that when the track starts up again, cues can get sent for it:

;; Clear record of last beat handled since the player is stopping.
;; If we restart in this same location, we should evaluate cues again.
(swap! qlc-cue-times update :sent dissoc device-number)

And that’s all the code! Although there is a fair bit, considering how handy a new feature it implements, I was happy to see how compact and clean it could be.

If you have any questions about using this, or ideas about new directions to take it, please raise them on the Zulip stream.