Matching Tracks Without Metadata Requests
This information is obsolete because metadata has become so fundamental to Beat Link Trigger. The current approach is to use metadata caches when you can’t actively request it. This document should be examined for historical reference only, although some of the techniques for working with raw rekordbox ID values may still be useful. There is no longer any support within the application for manually creating and attaching title and artist metadata subsets, however. |
If you find yourself in a situation where you are unable to query the players for track metadata, it is possible to build up your own structures that associate track IDs with metadata to assist in matching tracks. This was the only way to do it prior to the discovery of how to ask players for metadata; since it requires a lot more more effort and setup time, it has been moved into this separate document for reference in situations that justify that effort.
Recognizing Tracks
As described in the Player
Status Summary discussion, the most reliable way to match a track is
using the rekordbox-id
value, which uniquely identifies the track
within the media (USB stick or SD card) from which it was loaded.
However, these IDs are not unique across media—each attached USB
stick or SD card will number its tracks with IDs that start at 1 and
increment from there, so as soon as you have multiple media attached
to one or more players, there will multiple different tracks with the
same rekordbox-id
value. To be safe you will also need to consider
the track-source-player
value, which tells you the player from which
the track was loaded, and the track-source-slot
value, which will be
:sd-slot
for tracks loaded from SD cards, and :usb-slot
for tracks
loaded from USB drives.
When tracks are loaded from rekordbox running on a laptop,
track-source-player
will match the device number reported by
rekordbox (which seems to be 41 when there is only one copy running),
and track-source-slot
has the value :collection
.
For as long as the same set of media is mounted in the CDJs, the
combination of track-source-player
, track-source-slot
, and
rekordbox-id
will uniquely identify a track.
Depending on how many different tracks you want to watch for, and how differently you want to react to them, there are two different ways to approach matching them.
One Trigger per Track
If you are only dealing with a few tracks, and especially if you want to do fundamentally different things in response to detecting each track is being played, this approach might work well. The triggers are set to Watch Any Player, and the Enabled Filter expression activates each when any player has loaded the track that the trigger cares about. For example, in the following screen shot we have two triggers watching for two specific tracks:
The Enabled Filter expression for the first trigger is as follows:
(and
(= rekordbox-id 15) (= track-source-player 3) (= track-source-slot :usb-slot)
(>= beat-number 225))
This activates the trigger whenever a player has loaded the track with ID 15 from the USB drive attached to player 3, and playback has reached beat number 255. The Enabled Filter expression for the second trigger is similar:
(and
(= rekordbox-id 655) (= track-source-player 3) (= track-source-slot :usb-slot)
(>= beat-number 17))
Notice in the screen shot that both triggers are enabled, and watching different players, but the tracks were both loaded from the USB slot in player 3, which is exactly what the expressions specified. Also note that using normal Clojure expressions, you can combine matching the track with whatever other conditions you care about (in this example, beat position).
Adapting to Changes
Even with a small number of tracks, there is a drawback to the expressions we are using: If you set them up in advance, and then during the performance, the DJ needs to put the media into a different player, you will need to go into each trigger’s Enabled Filter separately and correct the player that it is looking for. This is tedious and error-prone, and with more than a few triggers, frankly unmanageable. But there is a better way.
We can use the Global Setup expression to set up global configuration information about the media library or libraries our DJs are using. For example, If you have USB sticks named “Show 1” and “extra”, with different tracks on each, you can configure them like this:
(add-media :show-1 :extra)
This sets up a map in the expression globals that can track the player and slot into which these track collections have been inserted for a given show. To actually assign them, choose
in the Triggers window menu:Each player found on the network will have a row in this window, and
using the menus you can assign any of the media libraries you
configured with add-media
to either of its slots, or you can
indicate that a slot contains no known media.
If you used :media-locations
entry has been
added to the expression globals, containing a map reflecting your
choices like this:
{2 {:usb-slot :show-1}
3 {:sd-slot :extra}}
With the media map properly configured for the current show, it can be used in each Enabled Filter expression, like this (but don’t panic if this expression looks complicated; it is just to explain the low-level workings, the idea is so useful that Beat Link Trigger offers a helper function to make it much easier, which will be explained next):
(and (= rekordbox-id 209)
(= (get-in @globals [:media-locations
track-source-player track-source-slot]) :show-1)
(>= beat-number 225))
This uses Clojure’s map traversal get-in
function with the
:media-locations
map to see what media key has been assigned to the
player slot the track was loaded from. Once each Enabled Filter
expression is written this way, as soon as you use the Set Media
Locations window to move media around, all of the filter expressions
immediately start watching for tracks loaded from the updated
locations.
As mentioned, though, that’s a lot of code to type for what is likely
to be a common desire! So Beat Link Trigger includes a convenience
macro called track-matches
which does it for you. Using it, we can
transform the above code to this much simpler version:
(and (track-matches :show-1 209)
(>= beat-number 225))
If you want to completely ignore track IDs and where they were loaded
from, and simply base your triggers on the tracks’ position within a
playlist, you can use the This is equivalent to including the following form in your Enabled Filter expression:
This isn’t the default, because playlists change more often than track IDs, and there is no way of telling what playlist a track was loaded from. But it can work for certain kinds of planned shows. |
One Trigger per Player
When you have a great many tracks that you want to watch for, managing so many triggers becomes awkward, even when you use globals to identify the player and slot where tracks should be loaded from. Instead, you can take that idea even further, and set up a global map that describes all the tracks you are interested in, along with whatever other information you need to react to them. In this approach, your Enabled Filter Expression will look up the track in the global map, and when it finds a match, mark the trigger as enabled, along with recording whatever other information about the track might be needed to react appropriately in a custom Activation expression.
Because more than one track which matches the global map might be loaded at the same time, this approach relies on having you set up an individual trigger for each player you want to watch, rather than having the trigger watch Any Player. |
So what does this global map of tracks look like? Here is one example (and if you don’t like the idea of creating such a deeply nested map on your own, we’ll introduce a macro to help you build it at the end of this section):
(swap! globals assoc :watched-tracks
{:show-1 ; The outermost key identifies each media library
;; Which is a map of track IDs to information about the track
{1 {:name "Rainbow (Jack rmx)" :beat-ccs {33 1}}
2 {:name "Best Day (Gent rmx)" :beat-ccs {17 2 65 3}}
73 {:name "Azuca (Club mix)" :beat-ccs {1 4}}
584 {:name "Bubble Control" :beat-ccs {9 5}}
873 {:name "Climax" :beat-ccs {63 6}}
}})
We build a series of nested maps. As noted, the outermost key is the keyword identifying a media library that can be assigned to a player slot using the Set Media Locations window. This allows the whole set of tracks to be found wherever it happens to get inserted for a given show. Inside that comes the main map identifying and describing the tracks we are watching for in that library.
We could have used a variety of structures for organizing this
information. Nested maps have a few advantages. As you’ll see in the
Enabled filter source below, it’s easy to navigate into them using the
get-in function. And this approach lets us keep track of more than
one rekordbox database containing tracks we want to watch for, by
simply adding additional media keywords paired with appropriate nested
track maps. Clojure for
the Brave and True is one place where you can learn more about
Clojure maps.
|
The map nested after the media identification keyword (:show-1
in
the example above) identifies the tracks we are interested in when
they are loaded from that library. It pairs the rekordbox ID number of
each track with whatever other information we might need to know about
that track. Finding a track’s ID in this map after we’ve navigated
down through the media keyword that has been assigned to player number
and slot from which a track was loaded means that we are interested in
the track, and the other information we attach to its ID lets us do
some pretty useful things.
In this example, we are tracking a :name
string for each track, and
another map we store as :beat-ccs
that will tell us the particular
beats within the track where we want to activate, as well as the MIDI
Controller Change number we want to send to identify the track that’s
activating when that beat is reached.
There is nothing special about the :name and :beat-ccs
keywords; you can use any keywords you want in your track maps, and
store any information that you need. You probably will want the track
names, since they can be displayed right in the interface as described
below, but the :beat-ccs entry is made up for this example, to show
how you can combine it with other code to
cause a trigger to be activated on specific beats, sending
configurable MIDI CC messages, for each track you care about.
|
The :name
entries in the track description maps play a double role.
First, they help us when looking at this expression itself to remember
what track each entry is matching. But the Enabled filter can also use
the name string to show the user what track has been matched. This
didn’t matter in the One Trigger per Track approach, because each
trigger had a Comment field where you could enter the track name it
matched. But in this new approach, we have only a single trigger for
each player, and that trigger will activate whenever the player loads
a track that is listed in the :watched-tracks
map. So, without
memorizing all the track IDs, how will you be able to tell which one
has been matched? Well, as described
above, the Enabled filter can
tell Beat Link Trigger to display the name of the track it has matched
by copying the :name
string to the key :track-description
in the
trigger locals
atom. Let’s look at the Enabled filter’s code now:
:sparkles: Don’t worry if this looks a little long—this first version shows how you could do it all on your own, so we can explain how each piece works. Right after that, we’ll introduce another convenience macro that lets you avoid writing most of this code. |
(let [media-key (get-in @globals [:media-locations track-source-player track-source-slot])]
(if-let [track (get-in @globals [:watched-tracks media-key rekordbox-id])]
(do ; Recognized track, so set the name, then enable if on a flagged beat
(swap! locals assoc :track-description (:name track))
(when-let [cc (get-in track [:beat-ccs beat-number])]
(swap! locals assoc :activate-cc cc)))
(do ; Unknown track, so clear name, then return nil to prevent activation
(swap! locals dissoc :track-description)
nil)))
The first part looks up the media key, if any, which has been assigned
(using the Media Locations window) to the player and slot from which
the track was loaded. The second line uses get-in
to navigate
through the nested map structure we created to describe tracks,
looking up a value by starting with the media key we found, then
looking for the rekordbox ID in the nested map. If, for example the
track was loaded from the media library :show-1
and the ID was 1
,
looking at the :watched-tracks
map above, that would set track
to:
{:name "Rainbow (Jack rmx)" :beat-ccs {33 1}}
When track
is successfully bound to a value like this, the if-let
form executes the first form in its body, labeled with the
“Recognized track” comment. That code copies the track
name that was found into the :track-description
local so that Beat
Link Trigger will display it in the trigger row, then goes on to check
whether the curent beat is one of the keys in the :beat-ccs
map. If
it is, the following value is copied to the trigger local named
:activate-cc
, which will be used by the custom Activation expression
below to send the appropriate MIDI CC message, and a non-empty value
is returned, which tells the trigger that it is enabled.
In this particular example, when the beat number is 33
, the trigger
will enable itself and set :activate-cc
to 1
. If the beat number
has any other value, the track name is still displayed, but the
trigger is disabled.
If any of the :watched-tracks
key lookups fail anywhere along the
way (no media key was assigned to the player and slot through Set
Media Locations, the media key can’t be found in the :watched-tracks
map, the track ID is not in the map, or perhaps track-source-slot
has the value :no-track
because no track has even been loaded) then
the if-let
form does not assign a value to track
, and it executes
the second part of its body (with the “Unknown track”
comment). That code removes the :track-description
local so Beat
Link Trigger will display its normal numerical descripton of the track
status, and returns nil
to indicate that the filter should not be
enabled.
Here’s what this set of expressions looks like in action:
Simplifying the Expression
As promised above, since looking up tracks this way is a commonly
useful task, Beat Link Trigger includes another convenience macro to
shorten the code you need to write. As long as you have structured
your nested track map as described in this example, starting with its
identifying keyword (:watched-tracks
in our example), followed by
the media library keyword and rekordbox ID as the nested keys to reach
each track’s information map, you can perform the lookup by simply
calling find-track
with the keyword you used to store your track map
in the globals. So we could shorten the expression above to be:
(if-let [track (find-track :watched-tracks)]
;; "then" and "else" forms omitted as they are the same as above
)
That helps—the first line is a lot shorter and simpler now. But the
middle part was still long enough that we felt like omitting it for
brevity in this example… can we do better? Well, again, setting the
track description based on some value that you have stored in your
track map seems like a very common desire, so the find-track
macro
can do that for you too. All you need to do is pass it a second
argument, telling it what keyword in the value it found in your map
should be used to set the track description. In our case, we had
:name
strings that we wanted to use. So we can rewrite the entire
Enabled Filter to this much simpler version:
(when-let [track (find-track :watched-tracks :name)]
(when-let [cc (get-in track [:beat-ccs beat-number])]
(swap! locals assoc :activate-cc cc)))
Notice that since now find-track
is taking care of setting the
:track-description
local to the value found at :name
in the
matched track map, as well as clearing it again if no track matches,
we no longer need the “else” logic we were using to take
care of cleaning up the description, so we can use a simpler
when-let
form rather than if-let
. And the only thing we need to
have in the body is whatever logic we want to use to decide when the
trigger is enabled for a matched track.
This is now a very compact, focused, and easy-to-understand filter, so
structuring the nested maps that you use to look up tracks in the way
that find-track
expects to find them is quite handy.
Fancier Name Formatting
You can actually do more than pass a keyword as the second argument to
find-track
; what it actually takes is a function that it calls with
the matching track map, and uses the result as the description.
Keywords work because in Clojure a keyword is also a function that
looks itself up in the map you pass it as an argument. Cool trick! But
if you want to combine multiple pieces of the map, or do anything
else, you can. As a small example, this is how you could limit the
length of the description to at most ten characters, even if the track
name is longer than that:
(when-let [track (find-track :watched-tracks #(subs (:name %) 0 (min 10 (count (:name %)))))]
(when-let [cc (get-in track [:beat-ccs beat-number])]
(swap! locals assoc :activate-cc cc)))
That syntax probably looks really strange; #(…)
is a compact way
to write an anonymous function in Clojure, and %
is the single
argument that function was called with. If you want to avoid such
terse and cryptic code, you can take the more readable approach of
actually declaring a named function in your Global Setup expression,
and then using it in your Enabled Filter expressions. So, adding this
to Global Setup:
(defn name-10-chars
"Looks up the :name key in a track map, and shortens
to 10 characters if needed."
[track]
(let [name (:name track)]
(subs name 0 (min 10 (count name)))))
defines the function name-10-chars
, which you can then use in your
Enabled Filter:
(when-let [track (find-track :watched-tracks name-10-chars)]
(when-let [cc (get-in track [:beat-ccs beat-number])]
(swap! locals assoc :activate-cc cc)))
Which brings us back to a concise, readable expression. And of course, your description format function can use more than one value from your track map, and have as much elaborate logic as you like.
Using your Track Details
Notice that in the screen shot above, as planned, each trigger is
configured to watch a single player. They each have identical copies
of the above Enabled filter installed, and are set to use it, which is
why the loaded track names are showing up in the blue Player Status
section. The first trigger is enabled, because that player is sitting
at the beat mentioned in the track’s :beat-ccs
map. As soon as that
player starts playing, the trigger will activate. But how will it know
which MIDI CC number it is supposed to send in its activation message?
That’s taken care of by a custom Activation expression that has been
installed:
(when trigger-output
(when-let [cc (:activate-cc @locals)]
(midi/midi-control trigger-output cc 127 (dec trigger-channel))))
This expression first checks that the trigger’s chosen MIDI Output can
be found (to avoid throwing exceptions trying to send to a missing
device), then looks for the value that the Enabled filter stored in
the :activate-cc
local. It then sends a MIDI CC message to that
controller number, with the value 127, on the channel chosen by the
trigger. (It calls dec
because the MIDI protocol actually uses the
numbers 0—15 to refer to the channels described as 1—16.)
In this example, the system being triggered only needs to know when
the track reaches that point, so the enabled filter can disable the
trigger as soon as the next beat is reached, and reactivate with a
different CC when another beat of interest is reached (the Just a Gent
remix of Best Day of my Life in this example sends CC 2 on beat 17,
and CC 3 on beat 65, using :beat-ccs {17 2 65 3}
).
If we need to send a CC to the same controller with the value 0 when the trigger deactivates, a very similar Deactivation expression can be installed:
(when trigger-output
(when-let [cc (:activate-cc @locals)]
(midi/midi-control trigger-output cc 0 (dec trigger-channel))))
And of course if you are using OSC to communicate rather than MIDI,
you are already writing custom Activation and Deactivation
expressions, and you can send much more information about the track
that way: the name, the actual rekordbox ID number, or some other
value that you add under a new key in the :watched-tracks
map. You
can structure this as richly as you need.
If you need the trigger to deactivate on specific beats, rather than always on the beat after it activates, that can be done with only slightly more code and tracking structures. I will leave it as an exercise to the reader, but if you get stuck or want to discuss your approach, please say so in the Gitter chat room.
Adding Tracks Individually
As promised a ways back (the track matching section has become pretty
big, and probably deserves to be moved to its own document), there is
a way to avoid having to create big nested maps all at once. If you
prefer, you can use the add-track
macro to add them one at a time.
It takes four required arguments followed by any additional keyword
and value pairs you want to store about your track. The initial four
arguments are:
source-key
-
Identifies how you want your track map named within the expression globals. We were using
:watched-tracks
. media-key
-
Identifies the media library on which the track you are adding resides. In our example this was either
:show-1
or:extra
. rekordbox-id
-
The ID of the track that you are adding.
track-name
-
The name of the track you are adding. This will be stored within the track map under the
:name
keyword.
Any additional arguments (there must be an even number) will be
treated as key and value pairs to be included within the map
describing the track you are adding. So here is how we could create
the same example map we have been using, with add-track
rather than
explicitly:
(add-track :watched-tracks :show-1 1 "Rainbow (Jack rmx)" :beat-ccs {33 1})
(add-track :watched-tracks :show-1 2 "Best Day (Gent rmx)" :beat-ccs {17 2 65 3})
(add-track :watched-tracks :show-1 73 "Azuca (Club mix)" :beat-ccs {1 4})
(add-track :watched-tracks :show-1 584 "Bubble Control" :beat-ccs {9 5})
(add-track :watched-tracks :show-1 873 "Climax" :beat-ccs {63 6})
Of course by changing the arguments you give, you can build maps for
multiple media libraries, without having to worry about how to nest
and indent your maps. And as noted above, the :beat-ccs
key and
values were invented for this example; you can store whatever keys and
values you need for your own purposes in your track entries.
Finally, here is an example of using the Globals Inspector to dive into the structure we have created, illustrating its hierarchical nature:
Historical Code
These are the functions that used to be part of the
beat-link.trigger/expressions
namespace to suport these examples.
Thankfully now that we can rely on metadata being available, and have
the Show interface, all this quaint intricacy seems like Victorian
scientific apparatus!
(defn- add-media*
"A convenience function for adding keywords that identify media
which can be assigned to player slots for use with track matching
and the Media Locations window."
[globals & ks]
{:pre [(every? keyword? ks)]}
(doseq [k ks]
(swap! globals update-in [:media k] merge {})))
(defmacro add-media
"Convenience macro for use in the Global Setup Expression.
Accepts a list of keywords that identify media which can be assigned
to player slots for use with track matching and the Media Locations
window."
[& ks]
` `(add-media* ~'globals ~@ks))
(defn track-matches*
"A convenience function for checking whether a track matches a
particular source and rekordbox ID number. Compares the source
player and slot of the track associated with the supplied status
update with the values found in the media map that is maintained in
the Beat Link Trigger globals atom by the Set Media Locations
window. Checks that the keyword specified as `source-key` is found under the
player and slot entries in the map, as desribed in the
[Adapting to
Changes](https://github.com/Deep-Symmetry/beat-link-trigger/blob/master/doc/README.adoc#adapting-to-changes)
section of the user guide). If `source-key` matches, `rekordbox-id`
is compared with the track ID present in the status update, and the
result is returned."
[status globals source-key rekordbox-id]
(and (= (get-in @globals [:media-locations (.getTrackSourcePlayer status) (track-source-slot status)]) source-key)
(= (.getRekordboxId status) rekordbox-id)))
(defmacro track-matches
"Convenience macro for use in an Enabled Filter Expression. Checks
whether the track contained in the current status update matches a
particular source and rekordbox ID number. Compares the track source
player and slot with the values found in a media map that is looked
up in the Beat Link Trigger globals atom using the specified
key (the map must be structured like the example shown in the
[Adapting to
Changes](https://github.com/Deep-Symmetry/beat-link-trigger/blob/master/doc/README.adoc#adapting-to-changes)
section of the user guide, with `:player` and `:slot` entries). If
the source matches, the id is compared with the specified value, and
returns the result."
[source-key rekordbox-id]
`(track-matches* ~'status ~'globals ~source-key ~rekordbox-id))
(defn find-track*
"A convenience function for looking for the track described by the
current status update within a nested map structured as described in
the [One Trigger per
Player](https://github.com/Deep-Symmetry/beat-link-trigger/blob/master/doc/README.adoc#one-trigger-per-player)
example in the user guide. The specified key is looked up in the
Beat Link Trigger globals atom, followed by the player number from
which the current track was loaded, its slot identifier, and the
rekordbox ID of the track. Any value found is returned. If any of
the lookups fails, `nil` is returned.
If you pass a value for `description-fn`, it will be called with the
matched track and the result will be used to set the track
description that Beat Link Trigger should display. A common and easy
use for this is to pass a keyword, and the value stored at that
keyword, if any, in the matched track, will be used as the track
description, since keywords are functions that look themselves up in
a map given to them as an argument."
([status locals globals source-key]
(find-track* status locals globals source-key nil))
([status locals globals source-key description-fn]
(let [media-key (get-in @globals [:media-locations (.getTrackSourcePlayer status) (track-source-slot status)])
result (get-in @globals [source-key media-key (.getRekordboxId status)])]
(when (some? source-key)
(if (and result (some? description-fn))
(swap! locals assoc :track-description (description-fn result))
(swap! locals dissoc :track-description)))
result)))
(defmacro find-track
"Convenience macro for use in an Enabled Filter Expression. Looks
for the track described by the current status update within a nested
map structured as described in the [One Trigger per
Player](https://github.com/Deep-Symmetry/beat-link-trigger/blob/master/doc/README.adoc#one-trigger-per-player)
example in the user guide. The specified key is looked up in the
Beat Link Trigger globals atom, followed by the player number from
which the current track was loaded, its slot identifier, and the
rekordbox ID of the track. Any value found is returned. If any of
the lookups fails, `nil` is returned.
If you pass a value for `description-fn`, it will be called with the
matched track and the result will be used to set the track
description that Beat Link Trigger should display. A common and easy
use for this is to pass a keyword, and the value stored at that
keyword, if any, in the matched track, will be used as the track
description, since keywords are functions that look themselves up in
a map given to them as an argument."
([source-key]
`(find-track* ~'status ~'locals ~'globals ~source-key nil))
([source-key description-fn]
`(find-track* ~'status ~'locals ~'globals ~source-key ~description-fn)))
(defn add-track*
"A convenience function for registering a track in the format
supported by `find-track`."
[globals source-key media-key rekordbox-id track]
(swap! globals assoc-in [source-key media-key rekordbox-id] track))
(defmacro add-track
"Convenience macro for use in the Global Setup Expression to add a
track to the structure used by `find-track`."
[source-key media-key rekordbox-id track-name & others]
`(add-track* ~'globals ~source-key ~media-key ~rekordbox-id (hash-map :name ~track-name ~@others)))