org.deepsymmetry.beatlink.VirtualCdj Maven / Gradle / Ivy
Show all versions of beat-link Show documentation
package org.deepsymmetry.beatlink;
import java.io.IOException;
import java.net.*;
import java.nio.ByteBuffer;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
import org.deepsymmetry.beatlink.data.MetadataFinder;
import org.deepsymmetry.beatlink.data.SlotReference;
import org.deepsymmetry.electro.Metronome;
import org.deepsymmetry.electro.Snapshot;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Provides the ability to create a virtual CDJ device that can lurk on a DJ Link network and receive packets sent to
* players, monitoring the detailed state of the other devices. This detailed information is helpful for augmenting
* what {@link BeatFinder} reports, allowing you to keep track of which player is the tempo master, how many beats of
* a track have been played, how close a player is getting to its next cue point, and more. It is also the foundation
* for finding out the rekordbox ID of the loaded track, which supports all the features associated with the
* {@link MetadataFinder}.
*
* @author James Elliott
*/
@SuppressWarnings("WeakerAccess")
public class VirtualCdj extends LifecycleParticipant {
private static final Logger logger = LoggerFactory.getLogger(VirtualCdj.class);
/**
* The port to which other devices will send status update messages.
*/
@SuppressWarnings("WeakerAccess")
public static final int UPDATE_PORT = 50002;
/**
* The socket used to receive device status packets while we are active.
*/
private final AtomicReference socket = new AtomicReference();
/**
* Check whether we are presently posing as a virtual CDJ and receiving device status updates.
*
* @return true if our socket is open, sending presence announcements, and receiving status packets
*/
public boolean isRunning() {
return socket.get() != null;
}
/**
* Return the address being used by the virtual CDJ to send its own presence announcement broadcasts.
*
* @return the local address we present to the DJ Link network
* @throws IllegalStateException if the {@code VirtualCdj} is not active
*/
public InetAddress getLocalAddress() {
ensureRunning();
return socket.get().getLocalAddress();
}
/**
* The broadcast address on which we can reach the DJ Link devices. Determined when we start
* up by finding the network interface address on which we are receiving the other devices'
* announcement broadcasts.
*/
private final AtomicReference broadcastAddress = new AtomicReference();
/**
* Return the broadcast address used to reach the DJ Link network.
*
* @return the address on which packets can be broadcast to the other DJ Link devices
* @throws IllegalStateException if the {@code VirtualCdj} is not active
*/
public InetAddress getBroadcastAddress() {
ensureRunning();
return broadcastAddress.get();
}
/**
* Keep track of the most recent updates we have seen, indexed by the address they came from.
*/
private final Map updates = new ConcurrentHashMap();
/**
* Should we try to use a device number in the range 1 to 4 if we find one is available?
*/
private final AtomicBoolean useStandardPlayerNumber = new AtomicBoolean(false);
/**
* When self-assigning a player number, should we try to use a value that is legal for a standard CDJ, in
* the range 1 to 4? By default, we do not, to avoid any potential conflict with real players. However, if
* the user is intending to use the {@link MetadataFinder}, and will always have fewer than four real players
* on the network, this can be set to {@code true}, and a device number in this range will be chosen if it
* is not in use on the network during startup.
*
* @param attempt true if self-assignment should try to use device numbers below 5 when available
*/
public void setUseStandardPlayerNumber(boolean attempt) {
useStandardPlayerNumber.set(attempt);
}
/**
* When self-assigning a player number, should we try to use a value that is legal for a standard CDJ, in
* the range 1 to 4? By default, we do not, to avoid any potential conflict with real players. However, if
* the user is intending to use the {@link MetadataFinder}, and will always have fewer than four real players
* on the network, this can be set to {@code true}, and a device number in this range will be chosen if it
* is not in use on the network during startup.
*
* @return true if self-assignment should try to use device numbers below 5 when available
*/
public boolean getUseStandardPlayerNumber() {
return useStandardPlayerNumber.get();
}
/**
* Get the device number that is used when sending presence announcements on the network to pose as a virtual CDJ.
* This starts out being zero unless you explicitly assign another value, which means that the VirtualCdj
* should assign itself an unused device number by watching the network when you call
* {@link #start()}. If {@link #getUseStandardPlayerNumber()} returns {@code true}, self-assignment will try to
* find a value in the range 1 to 4. Otherwise (or if those values are all used by other players), it will try to
* find a value in the range 5 to 15.
*
* @return the virtual player number
*/
public synchronized byte getDeviceNumber() {
return announcementBytes[DEVICE_NUMBER_OFFSET];
}
/**
* Set the device number to be used when sending presence announcements on the network to pose as a virtual CDJ.
* If this is set to zero before {@link #start()} is called, the {@code VirtualCdj} will watch the network to
* look for an unused device number, and assign itself that number during startup. If you
* explicitly assign a non-zero value, it will use that device number instead. Setting the value to zero while
* already up and running reassigns it to an unused value immediately. If {@link #getUseStandardPlayerNumber()}
* returns {@code true}, self-assignment will try to find a value in the range 1 to 4. Otherwise (or if those
* values are all used by other players), it will try to find a value in the range 5 to 15.
*
* The device number defaults to 0, enabling self-assignment, and will be reset to that each time the
* {@code VirtualCdj} is stopped.
*
* @param number the virtual player number
* @throws IllegalStateException if we are currently sending status updates
*/
@SuppressWarnings("WeakerAccess")
public synchronized void setDeviceNumber(byte number) {
if (isSendingStatus()) {
throw new IllegalStateException("Can't change device number while sending status packets.");
}
if (number == 0 && isRunning()) {
selfAssignDeviceNumber();
} else {
announcementBytes[DEVICE_NUMBER_OFFSET] = number;
}
}
/**
* The interval, in milliseconds, at which we post presence announcements on the network.
*/
private final AtomicInteger announceInterval = new AtomicInteger(1500);
/**
* Get the interval, in milliseconds, at which we broadcast presence announcements on the network to pose as
* a virtual CDJ.
*
* @return the announcement interval
*/
public int getAnnounceInterval() {
return announceInterval.get();
}
/**
* Set the interval, in milliseconds, at which we broadcast presence announcements on the network to pose as
* a virtual CDJ.
*
* @param interval the announcement interval
* @throws IllegalArgumentException if interval is not between 200 and 2000
*/
public void setAnnounceInterval(int interval) {
if (interval < 200 || interval > 2000) {
throw new IllegalArgumentException("Interval must be between 200 and 2000");
}
announceInterval.set(interval);
}
/**
* Used to construct the announcement packet we broadcast in order to participate in the DJ Link network.
* Some of these bytes are fixed, some get replaced by things like our device name and number, MAC address,
* and IP address, as described in Figure 8 in the
* Packet Analysis document.
*/
private static final byte[] announcementBytes = {
0x51, 0x73, 0x70, 0x74, 0x31, 0x57, 0x6d, 0x4a, 0x4f, 0x4c, 0x06, 0x00, 0x62, 0x65, 0x61, 0x74,
0x2d, 0x6c, 0x69, 0x6e, 0x6b, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x01, 0x02, 0x00, 0x36, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x01, 0x00, 0x00, 0x00, 0x01, 0x00
};
/**
* The location of the device name in the announcement packet.
*/
public static final int DEVICE_NAME_OFFSET = 0x0c;
/**
* The length of the device name in the announcement packet.
*/
public static final int DEVICE_NAME_LENGTH = 0x14;
/**
* The location of the device number in the announcement packet.
*/
public static final int DEVICE_NUMBER_OFFSET = 0x24;
/**
* Get the name to be used in announcing our presence on the network.
*
* @return the device name reported in our presence announcement packets
*/
public static String getDeviceName() {
return new String(announcementBytes, DEVICE_NAME_OFFSET, DEVICE_NAME_LENGTH).trim();
}
/**
* Set the name to be used in announcing our presence on the network. The name can be no longer than twenty
* bytes, and should be normal ASCII, no Unicode.
*
* @param name the device name to report in our presence announcement packets.
*/
public synchronized void setDeviceName(String name) {
if (name.getBytes().length > DEVICE_NAME_LENGTH) {
throw new IllegalArgumentException("name cannot be more than " + DEVICE_NAME_LENGTH + " bytes long");
}
Arrays.fill(announcementBytes, DEVICE_NAME_OFFSET, DEVICE_NAME_LENGTH, (byte)0);
System.arraycopy(name.getBytes(), 0, announcementBytes, DEVICE_NAME_OFFSET, name.getBytes().length);
}
/**
* Keep track of which device has reported itself as the current tempo master.
*/
private final AtomicReference tempoMaster = new AtomicReference();
/**
* Check which device is the current tempo master, returning the {@link DeviceUpdate} packet in which it
* reported itself to be master. If there is no current tempo master returns {@code null}. Note that when
* we are acting as tempo master ourselves in order to control player tempo and beat alignment, this will
* also have a {@code null} value, as there is no real player that is acting as master; we will instead
* send tempo and beat updates ourselves.
*
* @return the most recent update from a device which reported itself as the master
* @throws IllegalStateException if the {@code VirtualCdj} is not active
*/
public DeviceUpdate getTempoMaster() {
ensureRunning();
return tempoMaster.get();
}
/**
* Establish a new tempo master, and if it is a change from the existing one, report it to the listeners.
*
* @param newMaster the packet which caused the change of masters, or {@code null} if there is now no master.
*/
private void setTempoMaster(DeviceUpdate newMaster) {
DeviceUpdate oldMaster = tempoMaster.getAndSet(newMaster);
if ((newMaster == null && oldMaster != null) ||
(newMaster != null && ((oldMaster == null) || !newMaster.getAddress().equals(oldMaster.getAddress())))) {
// This is a change in master, so report it to any registered listeners
deliverMasterChangedAnnouncement(newMaster);
}
}
/**
* How large a tempo change is required before we consider it to be a real difference.
*/
private final AtomicLong tempoEpsilon = new AtomicLong(Double.doubleToLongBits(0.0001));
/**
* Find out how large a tempo change is required before we consider it to be a real difference.
*
* @return the BPM fraction that will trigger a tempo change update
*/
public double getTempoEpsilon() {
return Double.longBitsToDouble(tempoEpsilon.get());
}
/**
* Set how large a tempo change is required before we consider it to be a real difference.
*
* @param epsilon the BPM fraction that will trigger a tempo change update
*/
public void setTempoEpsilon(double epsilon) {
tempoEpsilon.set(Double.doubleToLongBits(epsilon));
}
/**
* Track the most recently reported master tempo.
*/
private final AtomicLong masterTempo = new AtomicLong();
/**
* Get the current master tempo.
*
* @return the most recently reported master tempo
* @throws IllegalStateException if the {@code VirtualCdj} is not active
*/
public double getMasterTempo() {
ensureRunning();
return Double.longBitsToDouble(masterTempo.get());
}
/**
* Establish a new master tempo, and if it is a change from the existing one, report it to the listeners.
*
* @param newTempo the newly reported master tempo.
*/
private void setMasterTempo(double newTempo) {
double oldTempo = Double.longBitsToDouble(masterTempo.getAndSet(Double.doubleToLongBits(newTempo)));
if ((getTempoMaster() != null) && (Math.abs(newTempo - oldTempo) > getTempoEpsilon())) {
// This is a change in tempo, so report it to any registered listeners, and update our metronome if we are synced.
if (isSynced()) {
metronome.setTempo(newTempo);
notifyBeatSenderOfChange();
}
deliverTempoChangedAnnouncement(newTempo);
}
}
/**
* Given an update packet sent to us, create the appropriate object to describe it.
*
* @param packet the packet received on our update port
* @return the corresponding {@link DeviceUpdate} subclass, or {@code nil} if the packet was not recognizable
*/
private DeviceUpdate buildUpdate(DatagramPacket packet) {
final int length = packet.getLength();
final Util.PacketType kind = Util.validateHeader(packet, UPDATE_PORT);
if (kind == null) {
logger.warn("Ignoring unrecognized packet sent to update port.");
return null;
}
switch (kind) {
case MIXER_STATUS:
if (length != 56) {
logger.warn("Processing a Mixer Status packet with unexpected length " + length + ", expected 56 bytes.");
}
if (length >= 56) {
return new MixerStatus(packet);
} else {
logger.warn("Ignoring too-short Mixer Status packet.");
return null;
}
case CDJ_STATUS:
if (length >= CdjStatus.MINIMUM_PACKET_SIZE) {
return new CdjStatus(packet);
} else {
logger.warn("Ignoring too-short CDJ Status packet with length " + length + " (we need " + CdjStatus.MINIMUM_PACKET_SIZE +
" bytes).");
return null;
}
case LOAD_TRACK_ACK:
logger.info("Received track load acknowledgment from player " + packet.getData()[0x21]);
return null;
case MEDIA_QUERY:
logger.warn("Received a media query packet, we don’t yet support responding to this.");
return null;
case MEDIA_RESPONSE:
deliverMediaDetailsUpdate(new MediaDetails(packet));
return null;
default:
logger.warn("Ignoring " + kind.name + " packet sent to update port.");
return null;
}
}
/**
* Process a device update once it has been received. Track it as the most recent update from its address,
* and notify any registered listeners, including master listeners if it results in changes to tracked state,
* such as the current master player and tempo. Also handles the Baroque dance of handing off the tempo master
* role from or to another device.
*/
private void processUpdate(DeviceUpdate update) {
updates.put(update.getAddress(), update);
// Keep track of the largest sync number we see.
if (update instanceof CdjStatus) {
int syncNumber = ((CdjStatus)update).getSyncNumber();
if (syncNumber > this.largestSyncCounter.get()) {
this.largestSyncCounter.set(syncNumber);
}
}
// Deal with the tempo master complexities, including handoff to/from us.
if (update.isTempoMaster()) {
final Integer packetYieldingTo = update.getDeviceMasterIsBeingYieldedTo();
if (packetYieldingTo == null) {
// This is a normal, non-yielding master packet. Update our notion of the current master, and,
// if we were yielding, finish that process, updating our sync number appropriately.
if (master.get()) {
if (nextMaster.get() == update.deviceNumber) {
syncCounter.set(largestSyncCounter.get() + 1);
} else {
if (nextMaster.get() == 0xff) {
logger.warn("Saw master asserted by player " + update.deviceNumber +
" when we were not yielding it.");
} else {
logger.warn("Expected to yield master role to player " + nextMaster.get() +
" but saw master asserted by player " + update.deviceNumber);
}
}
}
master.set(false);
nextMaster.set(0xff);
setTempoMaster(update);
setMasterTempo(update.getEffectiveTempo());
} else {
// This is a yielding master packet. If it is us that is being yielded to, take over master.
// Log a message if it was unsolicited, and a warning if it's coming from a different player than
// we asked.
if (packetYieldingTo == getDeviceNumber()) {
if (update.deviceNumber != masterYieldedFrom.get()) {
if (masterYieldedFrom.get() == 0) {
logger.info("Accepting unsolicited Master yield; we must be the only synced device playing.");
} else {
logger.warn("Expected player " + masterYieldedFrom.get() + " to yield master to us, but player " +
update.deviceNumber + " did.");
}
}
master.set(true);
masterYieldedFrom.set(0);
setTempoMaster(null);
setMasterTempo(getTempo());
}
}
} else {
// This update was not acting as a tempo master; if we thought it should be, update our records.
DeviceUpdate oldMaster = getTempoMaster();
if (oldMaster != null && oldMaster.getAddress().equals(update.getAddress())) {
// This device has resigned master status, and nobody else has claimed it so far
setTempoMaster(null);
}
}
deliverDeviceUpdate(update);
}
/**
* Process a beat packet, potentially updating the master tempo and sending our listeners a master
* beat notification. Does nothing if we are not active.
*/
void processBeat(Beat beat) {
if (isRunning() && beat.isTempoMaster()) {
setMasterTempo(beat.getEffectiveTempo());
deliverBeatAnnouncement(beat);
}
}
/**
* Scan a network interface to find if it has an address space which matches the device we are trying to reach.
* If so, return the address specification.
*
* @param aDevice the DJ Link device we are trying to communicate with
* @param networkInterface the network interface we are testing
* @return the address which can be used to communicate with the device on the interface, or null
*/
private InterfaceAddress findMatchingAddress(DeviceAnnouncement aDevice, NetworkInterface networkInterface) {
for (InterfaceAddress address : networkInterface.getInterfaceAddresses()) {
if ((address.getBroadcast() != null) &&
Util.sameNetwork(address.getNetworkPrefixLength(), aDevice.getAddress(), address.getAddress())) {
return address;
}
}
return null;
}
/**
* The number of milliseconds for which the {@link DeviceFinder} needs to have been watching the network in order
* for us to be confident we can choose a device number that will not conflict.
*/
private static final long SELF_ASSIGNMENT_WATCH_PERIOD = 4000;
/**
* Try to choose a device number, which we have not seen on the network. Start by making sure
* we have been watching long enough to have seen the other devices. Then, if {@link #useStandardPlayerNumber} is
* {@code true}, try to use a standard player number in the range 1-4 if possible. Otherwise (or if all those
* numbers are already in use), pick a number from 5 to 15.
*/
private boolean selfAssignDeviceNumber() {
final long now = System.currentTimeMillis();
final long started = DeviceFinder.getInstance().getFirstDeviceTime();
if (now - started < SELF_ASSIGNMENT_WATCH_PERIOD) {
try {
Thread.sleep(SELF_ASSIGNMENT_WATCH_PERIOD - (now - started)); // Sleep until we hit the right time
} catch (InterruptedException e) {
logger.warn("Interrupted waiting to self-assign device number, giving up.");
return false;
}
}
Set numbersUsed = new HashSet();
for (DeviceAnnouncement device : DeviceFinder.getInstance().getCurrentDevices()) {
numbersUsed.add(device.getNumber());
}
// Try all player numbers less than mixers use, only including the real player range if we are configured to.
final int startingNumber = (getUseStandardPlayerNumber() ? 1 : 5);
for (int result = startingNumber; result < 16; result++) {
if (!numbersUsed.contains(result)) { // We found one that is not used, so we can use it
setDeviceNumber((byte) result);
if (getUseStandardPlayerNumber() && (result > 4)) {
logger.warn("Unable to self-assign a standard player number, all are in use. Using number " +
result + ".");
}
return true;
}
}
logger.warn("Found no unused device numbers between " + startingNumber + " and 15, giving up.");
return false;
}
/**
* Once we have seen some DJ Link devices on the network, we can proceed to create a virtual player on that
* same network.
*
* @return true if we found DJ Link devices and were able to create the {@code VirtualCdj}.
* @throws SocketException if there is a problem opening a socket on the right network
*/
private boolean createVirtualCdj() throws SocketException {
// Find the network interface and address to use to communicate with the first device we found.
NetworkInterface matchedInterface = null;
InterfaceAddress matchedAddress = null;
DeviceAnnouncement aDevice = DeviceFinder.getInstance().getCurrentDevices().iterator().next();
for (NetworkInterface networkInterface : Collections.list(NetworkInterface.getNetworkInterfaces())) {
matchedAddress = findMatchingAddress(aDevice, networkInterface);
if (matchedAddress != null) {
matchedInterface = networkInterface;
break;
}
}
if (matchedAddress == null) {
logger.warn("Unable to find network interface to communicate with " + aDevice +
", giving up.");
return false;
}
if (getDeviceNumber() == 0) {
if (!selfAssignDeviceNumber()) {
return false;
}
}
// Copy the chosen interface's hardware and IP addresses into the announcement packet template
System.arraycopy(matchedInterface.getHardwareAddress(), 0, announcementBytes, 38, 6);
System.arraycopy(matchedAddress.getAddress().getAddress(), 0, announcementBytes, 44, 4);
broadcastAddress.set(matchedAddress.getBroadcast());
// Looking good. Open our communication socket and set up our threads.
socket.set(new DatagramSocket(UPDATE_PORT, matchedAddress.getAddress()));
// Inform the DeviceFinder to ignore our own device announcement packets.
DeviceFinder.getInstance().addIgnoredAddress(socket.get().getLocalAddress());
final byte[] buffer = new byte[512];
final DatagramPacket packet = new DatagramPacket(buffer, buffer.length);
// Create the update reception thread
Thread receiver = new Thread(null, new Runnable() {
@Override
public void run() {
boolean received;
while (isRunning()) {
try {
socket.get().receive(packet);
received = true;
} catch (IOException e) {
// Don't log a warning if the exception was due to the socket closing at shutdown.
if (isRunning()) {
// We did not expect to have a problem; log a warning and shut down.
logger.warn("Problem reading from DeviceStatus socket, stopping", e);
stop();
}
received = false;
}
try {
if (received && (packet.getAddress() != socket.get().getLocalAddress())) {
DeviceUpdate update = buildUpdate(packet);
if (update != null) {
processUpdate(update);
}
}
} catch (Throwable t) {
logger.warn("Problem processing device update packet", t);
}
}
}
}, "beat-link VirtualCdj status receiver");
receiver.setDaemon(true);
receiver.setPriority(Thread.MAX_PRIORITY);
receiver.start();
// Create the thread which announces our participation in the DJ Link network, to request update packets
Thread announcer = new Thread(null, new Runnable() {
@Override
public void run() {
while (isRunning()) {
sendAnnouncement(broadcastAddress.get());
}
}
}, "beat-link VirtualCdj announcement sender");
announcer.setDaemon(true);
announcer.start();
deliverLifecycleAnnouncement(logger, true);
return true;
}
/**
* Makes sure we get shut down if the {@link DeviceFinder} does, because we rely on it.
*/
private final LifecycleListener deviceFinderLifecycleListener = new LifecycleListener() {
@Override
public void started(LifecycleParticipant sender) {
logger.debug("VirtualCDJ doesn't have anything to do when the DeviceFinder starts");
}
@Override
public void stopped(LifecycleParticipant sender) {
if (isRunning()) {
logger.info("VirtualCDJ stopping because DeviceFinder has stopped.");
stop();
}
}
};
/**
* Start announcing ourselves and listening for status packets. If already active, has no effect. Requires the
* {@link DeviceFinder} to be active in order to find out how to communicate with other devices, so will start
* that if it is not already.
*
* @return true if we found DJ Link devices and were able to create the {@code VirtualCdj}, or it was already running.
* @throws SocketException if the socket to listen on port 50002 cannot be created
*/
@SuppressWarnings("UnusedReturnValue")
public synchronized boolean start() throws SocketException {
if (!isRunning()) {
// Set up so we know we have to shut down if the DeviceFinder shuts down.
DeviceFinder.getInstance().addLifecycleListener(deviceFinderLifecycleListener);
// Find some DJ Link devices so we can figure out the interface and address to use to talk to them
DeviceFinder.getInstance().start();
for (int i = 0; DeviceFinder.getInstance().getCurrentDevices().isEmpty() && i < 20; i++) {
try {
Thread.sleep(500);
} catch (InterruptedException e) {
logger.warn("Interrupted waiting for devices, giving up", e);
return false;
}
}
if (DeviceFinder.getInstance().getCurrentDevices().isEmpty()) {
logger.warn("No DJ Link devices found, giving up");
return false;
}
return createVirtualCdj();
}
return true; // We were already active
}
/**
* Stop announcing ourselves and listening for status updates.
*/
public synchronized void stop() {
if (isRunning()) {
try {
setSendingStatus(false);
} catch (Throwable t) {
logger.error("Problem stopping sending status during shutdown", t);
}
DeviceFinder.getInstance().removeIgnoredAddress(socket.get().getLocalAddress());
socket.get().close();
socket.set(null);
broadcastAddress.set(null);
updates.clear();
setTempoMaster(null);
setDeviceNumber((byte)0); // Set up for self-assignment if restarted.
deliverLifecycleAnnouncement(logger, false);
}
}
/**
* Send an announcement packet so the other devices see us as being part of the DJ Link network and send us
* updates.
*/
private void sendAnnouncement(InetAddress broadcastAddress) {
try {
DatagramPacket announcement = new DatagramPacket(announcementBytes, announcementBytes.length,
broadcastAddress, DeviceFinder.ANNOUNCEMENT_PORT);
socket.get().send(announcement);
Thread.sleep(getAnnounceInterval());
} catch (Throwable t) {
logger.warn("Unable to send announcement packet, shutting down", t);
stop();
}
}
/**
* Get the most recent status we have seen from all devices that are recent enough to be considered still
* active on the network.
* @return the most recent detailed status update received for all active devices
* @throws IllegalStateException if the {@code VirtualCdj} is not active
*/
public Set getLatestStatus() {
ensureRunning();
Set result = new HashSet();
long now = System.currentTimeMillis();
for (DeviceUpdate update : updates.values()) {
if (now - update.getTimestamp() <= DeviceFinder.MAXIMUM_AGE) {
result.add(update);
}
}
return Collections.unmodifiableSet(result);
}
/**
* Look up the most recent status we have seen for a device, given another update from it, which might be a
* beat packet containing far less information.
*
* Note: If you are trying to determine the current tempo or beat being played by the device, you should
* either use the status you just received, or
* {@link org.deepsymmetry.beatlink.data.TimeFinder#getLatestUpdateFor(int)} instead, because that
* combines both status updates and beat messages, and so is more likely to be current and definitive.
*
* @param device the update identifying the device for which current status information is desired
*
* @return the most recent detailed status update received for that device
* @throws IllegalStateException if the {@code VirtualCdj} is not active
*/
public DeviceUpdate getLatestStatusFor(DeviceUpdate device) {
ensureRunning();
return updates.get(device.getAddress());
}
/**
* Look up the most recent status we have seen for a device, given its device announcement packet as returned
* by {@link DeviceFinder#getCurrentDevices()}.
*
* Note: If you are trying to determine the current tempo or beat being played by the device, you should
* use {@link org.deepsymmetry.beatlink.data.TimeFinder#getLatestUpdateFor(int)} instead, because that
* combines both status updates and beat messages, and so is more likely to be current and definitive.
*
* @param device the announcement identifying the device for which current status information is desired
*
* @return the most recent detailed status update received for that device
* @throws IllegalStateException if the {@code VirtualCdj} is not active
*/
public DeviceUpdate getLatestStatusFor(DeviceAnnouncement device) {
ensureRunning();
return updates.get(device.getAddress());
}
/**
* Look up the most recent status we have seen for a device from a device identifying itself
* with the specified device number, if any.
*
* Note: If you are trying to determine the current tempo or beat being played by the device, you should
* use {@link org.deepsymmetry.beatlink.data.TimeFinder#getLatestUpdateFor(int)} instead, because that
* combines both status updates and beat messages, and so is more likely to be current and definitive.
*
* @param deviceNumber the device number of interest
*
* @return the matching detailed status update or null if none have been received
* @throws IllegalStateException if the {@code VirtualCdj} is not active
*/
public DeviceUpdate getLatestStatusFor(int deviceNumber) {
ensureRunning();
for (DeviceUpdate update : updates.values()) {
if (update.getDeviceNumber() == deviceNumber) {
return update;
}
}
return null;
}
/**
* Keeps track of the registered master listeners.
*/
private final Set masterListeners =
Collections.newSetFromMap(new ConcurrentHashMap());
/**
* Adds the specified master listener to receive device updates when there are changes related
* to the tempo master. If {@code listener} is {@code null} or already present in the set
* of registered listeners, no exception is thrown and no action is performed.
*
* To reduce latency, tempo master updates are delivered to listeners directly on the thread that is receiving them
* from the network, so if you want to interact with user interface objects in listener methods, you need to use
* javax.swing.SwingUtilities.invokeLater(Runnable)
* to do so on the Event Dispatch Thread.
*
* Even if you are not interacting with user interface objects, any code in the listener method
* must finish quickly, or it will add latency for other listeners, and master updates will back up.
* If you want to perform lengthy processing of any sort, do so on another thread.
*
* @param listener the master listener to add
*/
public void addMasterListener(MasterListener listener) {
if (listener != null) {
masterListeners.add(listener);
}
}
/**
* Removes the specified master listener so that it no longer receives device updates when
* there are changes related to the tempo master. If {@code listener} is {@code null} or not present
* in the set of registered listeners, no exception is thrown and no action is performed.
*
* @param listener the master listener to remove
*/
public void removeMasterListener(MasterListener listener) {
if (listener != null) {
masterListeners.remove(listener);
}
}
/**
* Get the set of master listeners that are currently registered.
*
* @return the currently registered tempo master listeners
*/
@SuppressWarnings("WeakerAccess")
public Set getMasterListeners() {
// Make a copy so callers get an immutable snapshot of the current state.
return Collections.unmodifiableSet(new HashSet(masterListeners));
}
/**
* Send a master changed announcement to all registered master listeners.
*
* @param update the message announcing the new tempo master
*/
private void deliverMasterChangedAnnouncement(final DeviceUpdate update) {
for (final MasterListener listener : getMasterListeners()) {
try {
listener.masterChanged(update);
} catch (Throwable t) {
logger.warn("Problem delivering master changed announcement to listener", t);
}
}
}
/**
* Send a tempo changed announcement to all registered master listeners.
*
* @param tempo the new master tempo
*/
private void deliverTempoChangedAnnouncement(final double tempo) {
for (final MasterListener listener : getMasterListeners()) {
try {
listener.tempoChanged(tempo);
} catch (Throwable t) {
logger.warn("Problem delivering tempo changed announcement to listener", t);
}
}
}
/**
* Send a beat announcement to all registered master listeners.
*
* @param beat the beat sent by the tempo master
*/
private void deliverBeatAnnouncement(final Beat beat) {
for (final MasterListener listener : getMasterListeners()) {
try {
listener.newBeat(beat);
} catch (Throwable t) {
logger.warn("Problem delivering master beat announcement to listener", t);
}
}
}
/**
* Keeps track of the registered device update listeners.
*/
private final Set updateListeners =
Collections.newSetFromMap(new ConcurrentHashMap());
/**
* Adds the specified device update listener to receive device updates whenever they come in.
* If {@code listener} is {@code null} or already present in the list
* of registered listeners, no exception is thrown and no action is performed.
*
* To reduce latency, device updates are delivered to listeners directly on the thread that is receiving them
* from the network, so if you want to interact with user interface objects in listener methods, you need to use
* javax.swing.SwingUtilities.invokeLater(Runnable)
* to do so on the Event Dispatch Thread.
*
* Even if you are not interacting with user interface objects, any code in the listener method
* must finish quickly, or it will add latency for other listeners, and device updates will back up.
* If you want to perform lengthy processing of any sort, do so on another thread.
*
* @param listener the device update listener to add
*/
@SuppressWarnings("SameParameterValue")
public void addUpdateListener(DeviceUpdateListener listener) {
if (listener != null) {
updateListeners.add(listener);
}
}
/**
* Removes the specified device update listener so it no longer receives device updates when they come in.
* If {@code listener} is {@code null} or not present
* in the list of registered listeners, no exception is thrown and no action is performed.
*
* @param listener the device update listener to remove
*/
public void removeUpdateListener(DeviceUpdateListener listener) {
if (listener != null) {
updateListeners.remove(listener);
}
}
/**
* Get the set of device update listeners that are currently registered.
*
* @return the currently registered update listeners
*/
public Set getUpdateListeners() {
// Make a copy so callers get an immutable snapshot of the current state.
return Collections.unmodifiableSet(new HashSet(updateListeners));
}
/**
* Send a device update to all registered update listeners.
*
* @param update the device update that has just arrived
*/
private void deliverDeviceUpdate(final DeviceUpdate update) {
for (DeviceUpdateListener listener : getUpdateListeners()) {
try {
listener.received(update);
} catch (Throwable t) {
logger.warn("Problem delivering device update to listener", t);
}
}
}
/**
* Keeps track of the registered media details listeners.
*/
private final Set detailsListeners =
Collections.newSetFromMap(new ConcurrentHashMap());
/**
* Adds the specified media details listener to receive detail responses whenever they come in.
* If {@code listener} is {@code null} or already present in the list
* of registered listeners, no exception is thrown and no action is performed.
*
* To reduce latency, device updates are delivered to listeners directly on the thread that is receiving them
* from the network, so if you want to interact with user interface objects in listener methods, you need to use
* javax.swing.SwingUtilities.invokeLater(Runnable)
* to do so on the Event Dispatch Thread.
*
* Even if you are not interacting with user interface objects, any code in the listener method
* must finish quickly, or it will add latency for other listeners, and detail updates will back up.
* If you want to perform lengthy processing of any sort, do so on another thread.
*
* @param listener the media details listener to add
*/
public void addMediaDetailsListener(MediaDetailsListener listener) {
if (listener != null) {
detailsListeners.add(listener);
}
}
/**
* Removes the specified media details listener so it no longer receives detail responses when they come in.
* If {@code listener} is {@code null} or not present
* in the list of registered listeners, no exception is thrown and no action is performed.
*
* @param listener the media details listener to remove
*/
public void removeMediaDetailsListener(MediaDetailsListener listener) {
if (listener != null) {
detailsListeners.remove(listener);
}
}
/**
* Get the set of media details listeners that are currently registered.
*
* @return the currently registered details listeners
*/
public Set getMediaDetailsListeners() {
// Make a copy so callers get an immutable snapshot of the current state.
return Collections.unmodifiableSet(new HashSet(detailsListeners));
}
/**
* Send a media details response to all registered listeners.
*
* @param details the response that has just arrived
*/
private void deliverMediaDetailsUpdate(final MediaDetails details) {
for (MediaDetailsListener listener : getMediaDetailsListeners()) {
try {
listener.detailsAvailable(details);
} catch (Throwable t) {
logger.warn("Problem delivering media details response to listener", t);
}
}
}
/**
* Finish the work of building and sending a protocol packet.
*
* @param kind the type of packet to create and send
* @param payload the content which will follow our device name in the packet
* @param destination where the packet should be sent
* @param port the port to which the packet should be sent
*
* @throws IOException if there is a problem sending the packet
*/
@SuppressWarnings("SameParameterValue")
private void assembleAndSendPacket(Util.PacketType kind, byte[] payload, InetAddress destination, int port) throws IOException {
DatagramPacket packet = Util.buildPacket(kind,
ByteBuffer.wrap(announcementBytes, DEVICE_NAME_OFFSET, DEVICE_NAME_LENGTH).asReadOnlyBuffer(),
ByteBuffer.wrap(payload));
packet.setAddress(destination);
packet.setPort(port);
socket.get().send(packet);
}
/**
* The bytes at the end of a media query packet.
*/
private final static byte[] MEDIA_QUERY_PAYLOAD = { 0x01,
0x00, 0x0d, 0x00, 0x0c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
/**
* Ask a device for information about the media mounted in a particular slot. Will update the
* {@link MetadataFinder} when a response is received.
*
* @param slot the slot holding media we want to know about.
*
* @throws IOException if there is a problem sending the request.
*/
public void sendMediaQuery(SlotReference slot) throws IOException {
final DeviceAnnouncement announcement = DeviceFinder.getInstance().getLatestAnnouncementFrom(slot.player);
if (announcement == null) {
throw new IllegalArgumentException("Device for " + slot + " not found on network.");
}
ensureRunning();
byte[] payload = new byte[MEDIA_QUERY_PAYLOAD.length];
System.arraycopy(MEDIA_QUERY_PAYLOAD, 0, payload, 0, MEDIA_QUERY_PAYLOAD.length);
payload[2] = getDeviceNumber();
System.arraycopy(announcementBytes, 44, payload, 5, 4); // Copy in our IP address.
payload[12] = (byte)slot.player;
payload[16] = slot.slot.protocolValue;
assembleAndSendPacket(Util.PacketType.MEDIA_QUERY, payload, announcement.getAddress(), UPDATE_PORT);
}
/**
* The bytes at the end of a sync control command packet.
*/
private final static byte[] SYNC_CONTROL_PAYLOAD = { 0x01,
0x00, 0x0d, 0x00, 0x08, 0x00, 0x00, 0x00, 0x0d, 0x00, 0x00, 0x00, 0x0f };
/**
* Assemble and send a packet that performs sync control, turning a device's sync mode on or off, or telling it
* to become the tempo master.
*
* @param target an update from the device whose sync state is to be set
* @param command the byte identifying the specific sync command to be sent
*
* @throws IOException if there is a problem sending the command to the device
*/
private void sendSyncControlCommand(DeviceUpdate target, byte command) throws IOException {
ensureRunning();
byte[] payload = new byte[SYNC_CONTROL_PAYLOAD.length];
System.arraycopy(SYNC_CONTROL_PAYLOAD, 0, payload, 0, SYNC_CONTROL_PAYLOAD.length);
payload[2] = getDeviceNumber();
payload[8] = getDeviceNumber();
payload[12] = command;
assembleAndSendPacket(Util.PacketType.SYNC_CONTROL, payload, target.getAddress(), BeatFinder.BEAT_PORT);
}
/**
* Tell a device to turn sync on or off.
*
* @param deviceNumber the device whose sync state is to be set
* @param synced {@code} true if sync should be turned on, else it will be turned off
*
* @throws IOException if there is a problem sending the command to the device
* @throws IllegalStateException if the {@code VirtualCdj} is not active
* @throws IllegalArgumentException if {@code deviceNumber} is not found on the network
*/
public void sendSyncModeCommand(int deviceNumber, boolean synced) throws IOException {
final DeviceUpdate update = getLatestStatusFor(deviceNumber);
if (update == null) {
throw new IllegalArgumentException("Device " + deviceNumber + " not found on network.");
}
sendSyncModeCommand(update, synced);
}
/**
* Tell a device to turn sync on or off.
*
* @param target an update from the device whose sync state is to be set
* @param synced {@code} true if sync should be turned on, else it will be turned off
*
* @throws IOException if there is a problem sending the command to the device
* @throws IllegalStateException if the {@code VirtualCdj} is not active
* @throws NullPointerException if {@code update} is {@code null}
*/
public void sendSyncModeCommand(DeviceUpdate target, boolean synced) throws IOException {
sendSyncControlCommand(target, synced? (byte)0x10 : (byte)0x20);
}
/**
* Tell a device to become tempo master.
*
* @param deviceNumber the device we want to take over the role of tempo master
*
* @throws IOException if there is a problem sending the command to the device
* @throws IllegalStateException if the {@code VirtualCdj} is not active
* @throws IllegalArgumentException if {@code deviceNumber} is not found on the network
*/
public void appointTempoMaster(int deviceNumber) throws IOException {
final DeviceUpdate update = getLatestStatusFor(deviceNumber);
if (update == null) {
throw new IllegalArgumentException("Device " + deviceNumber + " not found on network.");
}
appointTempoMaster(update);
}
/**
* Tell a device to become tempo master.
*
* @param target an update from the device that we want to take over the role of tempo master
*
* @throws IOException if there is a problem sending the command to the device
* @throws IllegalStateException if the {@code VirtualCdj} is not active
* @throws NullPointerException if {@code update} is {@code null}
*/
public void appointTempoMaster(DeviceUpdate target) throws IOException {
sendSyncControlCommand(target, (byte)0x01);
}
/**
* The bytes at the end of a fader start command packet.
*/
private final static byte[] FADER_START_PAYLOAD = { 0x01,
0x00, 0x0d, 0x00, 0x04, 0x02, 0x02, 0x02, 0x02 };
/**
* Broadcast a packet that tells some players to start playing and others to stop. If a player number is in
* both sets, it will be told to stop. Numbers outside the range 1 to 4 are ignored.
*
* @param deviceNumbersToStart the players that should start playing if they aren't already
* @param deviceNumbersToStop the players that should stop playing
*
* @throws IOException if there is a problem broadcasting the command to the players
* @throws IllegalStateException if the {@code VirtualCdj} is not active
*/
public void sendFaderStartCommand(Set deviceNumbersToStart, Set deviceNumbersToStop) throws IOException {
ensureRunning();
byte[] payload = new byte[FADER_START_PAYLOAD.length];
System.arraycopy(FADER_START_PAYLOAD, 0, payload, 0, FADER_START_PAYLOAD.length);
payload[2] = getDeviceNumber();
for (int i = 1; i <= 4; i++) {
if (deviceNumbersToStart.contains(i)) {
payload[i + 4] = 0;
}
if (deviceNumbersToStop.contains(i)) {
payload[i + 4] = 1;
}
}
assembleAndSendPacket(Util.PacketType.FADER_START_COMMAND, payload, getBroadcastAddress(), BeatFinder.BEAT_PORT);
}
/**
* The bytes at the end of a channels on-air report packet.
*/
private final static byte[] CHANNELS_ON_AIR_PAYLOAD = { 0x01,
0x00, 0x0d, 0x00, 0x09, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 };
/**
* Broadcast a packet that tells the players which channels are on the air (audible in the mixer output).
* Numbers outside the range 1 to 4 are ignored. If there is an actual DJM mixer on the network, it will
* be sending these packets several times per second, so the results of calling this method will be quickly
* overridden.
*
* @param deviceNumbersOnAir the players whose channels are currently on the air
*
* @throws IOException if there is a problem broadcasting the command to the players
* @throws IllegalStateException if the {@code VirtualCdj} is not active
*/
public void sendOnAirCommand(Set deviceNumbersOnAir) throws IOException {
ensureRunning();
byte[] payload = new byte[CHANNELS_ON_AIR_PAYLOAD.length];
System.arraycopy(CHANNELS_ON_AIR_PAYLOAD, 0, payload, 0, CHANNELS_ON_AIR_PAYLOAD.length);
payload[2] = getDeviceNumber();
for (int i = 1; i <= 4; i++) {
if (deviceNumbersOnAir.contains(i)) {
payload[i + 4] = 1;
}
}
assembleAndSendPacket(Util.PacketType.CHANNELS_ON_AIR, payload, getBroadcastAddress(), BeatFinder.BEAT_PORT);
}
/**
* The bytes at the end of a load-track command packet.
*/
private final static byte[] LOAD_TRACK_PAYLOAD = { 0x01,
0x00, 0x0d, 0x00, 0x34, 0x0d, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x32, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
};
/**
* Send a packet to the target player telling it to load the specified track from the specified source player.
*
* @param targetPlayer the device number of the player that you want to have load a track
* @param rekordboxId the identifier of a track within the source player's rekordbox database
* @param sourcePlayer the device number of the player from which the track should be loaded
* @param sourceSlot the media slot from which the track should be loaded
* @param sourceType the type of track to be loaded
*
* @throws IOException if there is a problem sending the command
* @throws IllegalStateException if the {@code VirtualCdj} is not active or the target device cannot be found
*/
public void sendLoadTrackCommand(int targetPlayer, int rekordboxId,
int sourcePlayer, CdjStatus.TrackSourceSlot sourceSlot, CdjStatus.TrackType sourceType)
throws IOException {
final DeviceUpdate update = getLatestStatusFor(targetPlayer);
if (update == null) {
throw new IllegalArgumentException("Device " + targetPlayer + " not found on network.");
}
sendLoadTrackCommand(update, rekordboxId, sourcePlayer, sourceSlot, sourceType);
}
/**
* Send a packet to the target device telling it to load the specified track from the specified source player.
*
* @param target an update from the player that you want to have load a track
* @param rekordboxId the identifier of a track within the source player's rekordbox database
* @param sourcePlayer the device number of the player from which the track should be loaded
* @param sourceSlot the media slot from which the track should be loaded
* @param sourceType the type of track to be loaded
*
* @throws IOException if there is a problem sending the command
* @throws IllegalStateException if the {@code VirtualCdj} is not active
*/
public void sendLoadTrackCommand(DeviceUpdate target, int rekordboxId,
int sourcePlayer, CdjStatus.TrackSourceSlot sourceSlot, CdjStatus.TrackType sourceType)
throws IOException {
ensureRunning();
byte[] payload = new byte[LOAD_TRACK_PAYLOAD.length];
System.arraycopy(LOAD_TRACK_PAYLOAD, 0, payload, 0, LOAD_TRACK_PAYLOAD.length);
payload[0x02] = getDeviceNumber();
payload[0x05] = getDeviceNumber();
payload[0x09] = (byte)sourcePlayer;
payload[0x0a] = sourceSlot.protocolValue;
payload[0x0b] = sourceType.protocolValue;
Util.numberToBytes(rekordboxId, payload, 0x0d, 4);
assembleAndSendPacket(Util.PacketType.LOAD_TRACK_COMMAND, payload, target.getAddress(), UPDATE_PORT);
}
/**
* The bytes at the end of a load-track command packet.
*/
private final static byte[] YIELD_ACK_PAYLOAD = { 0x01,
0x00, 0x0d, 0x00, 0x08, 0x00, 0x00, 0x00, 0x0d, 0x00, 0x00, 0x00, 0x01
};
/**
* The number of milliseconds that we will wait between sending status packets, when we are sending them.
*/
private int statusInterval = 200;
/**
* Check how often we will send status packets, if we are configured to send them.
*
* @return the millisecond interval that will pass between status packets we send
*/
public synchronized int getStatusInterval() {
return statusInterval;
}
/**
* Change the interval at which we will send status packets, if we are configured to send them. You probably won't
* need to change this unless you are experimenting. If you find an environment where the default of 200ms doesn't
* work, please open an issue.
*
* @param interval the millisecond interval that will pass between each status packet we send
*
* @throws IllegalArgumentException if {@code interval} is less than 20 or more than 2000
*/
public synchronized void setStatusInterval(int interval) {
if (interval < 20 || interval > 2000) {
throw new IllegalArgumentException("interval must be between 20 and 2000");
}
this.statusInterval = interval;
}
/**
* Makes sure we stop sending status if the {@link BeatFinder} shuts down, because we rely on it.
*/
private final LifecycleListener beatFinderLifecycleListener = new LifecycleListener() {
@Override
public void started(LifecycleParticipant sender) {
logger.debug("VirtualCDJ doesn't have anything to do when the BeatFinder starts");
}
@Override
public void stopped(LifecycleParticipant sender) {
if (isSendingStatus()) {
logger.info("VirtualCDJ no longer sending status updates because BeatFinder has stopped.");
try {
setSendingStatus(false);
} catch (Exception e) {
logger.error("Problem stopping sending status packets when the BeatFinder stopped", e);
}
}
}
};
/**
* Will hold an instance when we are actively sending beats, so we can let it know when the metronome changes,
* and when it is time to shut down.
*/
private final AtomicReference beatSender = new AtomicReference();
/**
* Check whether we are currently running a {@link BeatSender}; if we are, notify it that there has been a change
* to the metronome timeline, so it needs to wake up and reassess its situation.
*/
private void notifyBeatSenderOfChange() {
final BeatSender activeSender = beatSender.get();
if (activeSender != null) {
activeSender.timelineChanged();
}
}
/**
* The bytes following the device name in a beat packet.
*/
private static final byte[] BEAT_PAYLOAD = { 0x01,
0x00, 0x0d, 0x00, 0x3c, 0x01, 0x01, 0x01, 0x01, 0x02, 0x02, 0x02, 0x02, 0x10, 0x10, 0x10, 0x10,
0x04, 0x04, 0x04, 0x04, 0x20, 0x20, 0x20, 0x20, 0x08, 0x08, 0x08, 0x08, -1, -1, -1, -1,
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
-1, -1, -1, -1, 0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0b, 0x00, 0x00, 0x0d};
/**
* Sends a beat packet. Generally this should only be invoked when our {@link BeatSender} has determined that it is
* time to do so, but it is public to allow experimentation.
*
* @return the beat number that was sent, computed from the current (or stopped) playback position
*/
public long sendBeat() {
return sendBeat(getPlaybackPosition());
}
/**
* Sends a beat packet. Generally this should only be invoked when our {@link BeatSender} has determined that it is
* time to do so, but it is public to allow experimentation.
*
* @param snapshot the time at which the beat to be sent occurred, for computation of its beat number
*
* @return the beat number that was sent
*/
public long sendBeat(Snapshot snapshot) {
byte[] payload = new byte[BEAT_PAYLOAD.length];
System.arraycopy(BEAT_PAYLOAD, 0, payload, 0, BEAT_PAYLOAD.length);
payload[0x02] = getDeviceNumber();
Util.numberToBytes((int)snapshot.getBeatInterval(), payload, 0x05, 4);
Util.numberToBytes((int)(snapshot.getBeatInterval() * 2), payload, 0x09, 4);
Util.numberToBytes((int)(snapshot.getBeatInterval() * 4), payload, 0x11, 4);
Util.numberToBytes((int)(snapshot.getBeatInterval() * 8), payload, 0x19, 4);
final int beatsLeft = 5 - snapshot.getBeatWithinBar();
final int nextBar = (int)(snapshot.getBeatInterval() * beatsLeft);
Util.numberToBytes(nextBar, payload, 0x0d, 4);
Util.numberToBytes(nextBar + (int)snapshot.getBarInterval(), payload, 0x15, 4);
Util.numberToBytes((int)Math.round(snapshot.getTempo() * 100), payload, 0x3b, 2);
payload[0x3d] = (byte)snapshot.getBeatWithinBar();
payload[0x40] = getDeviceNumber();
try {
assembleAndSendPacket(Util.PacketType.BEAT, payload, broadcastAddress.get(), BeatFinder.BEAT_PORT);
} catch (IOException e) {
logger.error("VirtualCdj Failed to send beat packet.", e);
}
return snapshot.getBeat();
}
/**
* Will hold a non-null value when we are sending our own status packets, which can be used to stop the thread
* doing so. Most uses of Beat Link will not require this level of activity. However, if you want to be able to
* take over the tempo master role, and control the tempo and beat alignment of other players, you will need to
* turn on this feature, which also requires that you are using one of the standard player numbers, 1-4.
*/
private AtomicBoolean sendingStatus = null;
/**
* Control whether the Virtual CDJ sends status packets to the other players. Most uses of Beat Link will not
* require this level of activity. However, if you want to be able to take over the tempo master role, and control
* the tempo and beat alignment of other players, you will need to turn on this feature, which also requires that
* you are using one of the standard player numbers, 1-4.
*
* @param send if {@code true} we will send status packets, and can participate in (and control) tempo and beat sync
*
* @throws IllegalStateException if the virtual CDJ is not running, or if it is not using a device number in the
* range 1 through 4
* @throws IOException if there is a problem starting the {@link BeatFinder}
*/
public synchronized void setSendingStatus(boolean send) throws IOException {
if (isSendingStatus() == send) {
return;
}
if (send) { // Start sending status packets.
ensureRunning();
if ((getDeviceNumber() < 1) || (getDeviceNumber() > 4)) {
throw new IllegalStateException("Can only send status when using a standard player number, 1 through 4.");
}
BeatFinder.getInstance().start();
BeatFinder.getInstance().addLifecycleListener(beatFinderLifecycleListener);
final AtomicBoolean stillRunning = new AtomicBoolean(true);
sendingStatus = stillRunning; // Allow other threads to stop us when necessary.
Thread sender = new Thread(null, new Runnable() {
@Override
public void run() {
while (stillRunning.get()) {
sendStatus();
try {
Thread.sleep(getStatusInterval());
} catch (InterruptedException e) {
logger.warn("beat-link VirtualCDJ status sender thread was interrupted; continuing");
}
}
}
}, "beat-link VirtualCdj status sender");
sender.setDaemon(true);
sender.start();
if (isSynced()) { // If we are supposed to be synced, we need to respond to master beats and tempo changes.
addMasterListener(ourSyncMasterListener);
}
if (isPlaying()) { // Start the beat sender too, if we are supposed to be playing.
beatSender.set(new BeatSender(metronome));
}
} else { // Stop sending status packets, and responding to master beats and tempo changes if we were synced.
BeatFinder.getInstance().removeLifecycleListener(beatFinderLifecycleListener);
removeMasterListener(ourSyncMasterListener);
sendingStatus.set(false); // Stop the status sending thread.
sendingStatus = null; // Indicate that we are no longer sending status.
final BeatSender activeSender = beatSender.get(); // And stop the beat sender if we have one.
if (activeSender != null) {
activeSender.shutDown();
beatSender.set(null);
}
}
}
/**
* Check whether we are currently sending status packets.
*
* @return {@code true} if we are sending status packets, and can participate in (and control) tempo and beat sync
*/
public synchronized boolean isSendingStatus() {
return (sendingStatus != null);
}
/**
* Used to keep time when we are pretending to play a track, and to allow us to sync with other players when we
* are told to do so.
*/
private final Metronome metronome = new Metronome();
/**
* Keeps track of our position when we are not playing; this beat gets loaded into the metronome when we start
* playing, and it will keep time from there. When we stop again, we save the metronome's current beat here.
*/
private final AtomicReference whereStopped =
new AtomicReference(metronome.getSnapshot(metronome.getStartTime()));
/**
* Indicates whether we should currently pretend to be playing. This will only have an impact when we are sending
* status and beat packets.
*/
private final AtomicBoolean playing = new AtomicBoolean(false);
/**
* Controls whether we report that we are playing. This will only have an impact when we are sending status and
* beat packets.
*
* @param playing {@code true} if we should seem to be playing
*/
public void setPlaying(boolean playing) {
if (this.playing.get() == playing) {
return;
}
this.playing.set(playing);
if (playing) {
metronome.jumpToBeat(whereStopped.get().getBeat());
if (isSendingStatus()) { // Need to also start the beat sender.
beatSender.set(new BeatSender(metronome));
}
} else {
final BeatSender activeSender = beatSender.get();
if (activeSender != null) { // We have a beat sender we need to stop.
activeSender.shutDown();
beatSender.set(null);
}
whereStopped.set(metronome.getSnapshot());
}
}
/**
* Check whether we are pretending to be playing. This will only have an impact when we are sending status and
* beat packets.
*
* @return {@code true} if we are reporting active playback
*/
public boolean isPlaying() {
return playing.get();
}
/**
* Find details about the current simulated playback position.
*
* @return the current (or last, if we are stopped) playback state
*/
public Snapshot getPlaybackPosition() {
if (playing.get()) {
return metronome.getSnapshot();
} else {
return whereStopped.get();
}
}
/**
* Nudge the playback position by the specified number of milliseconds, to support synchronization with an
* external clock. Positive values move playback forward in time, while negative values jump back. If we are
* sending beat packets, notify the beat sender that the timeline has changed.
*
* If the shift would put us back before beat one, we will jump forward a bar to correct that. It is thus not
* safe to jump backwards more than a bar's worth of time.
*
* @param ms the number of millisecond to shift the simulated playback position
*/
public void adjustPlaybackPosition(int ms) {
if (ms != 0) {
metronome.adjustStart(-ms);
if (metronome.getBeat() < 1) {
metronome.adjustStart(Math.round(Metronome.beatsToMilliseconds(metronome.getBeatsPerBar(), metronome.getTempo())));
}
notifyBeatSenderOfChange();
}
}
/**
* Indicates whether we are currently the tempo master. Will only be meaningful (and get set) if we are sending
* status packets.
*/
private final AtomicBoolean master = new AtomicBoolean(false);
private static final byte[] MASTER_HANDOFF_REQUEST_PAYLOAD = { 0x01,
0x00, 0x0d, 0x00, 0x04, 0x00, 0x00, 0x00, 0x0d };
/**
* When have started the process of requesting the tempo master role from another player, this gets set to its
* device number. Otherwise it has the value {@code 0}.
*/
private final AtomicInteger requestingMasterRoleFromPlayer = new AtomicInteger(0);
/**
* Arrange to become the tempo master. Starts a sequence of interactions with the other players that should end
* up with us in charge of the group tempo and beat alignment.
*
* @throws IllegalStateException if we are not sending status updates
* @throws IOException if there is a problem sending the master yield request
*/
public synchronized void becomeTempoMaster() throws IOException {
logger.debug("Trying to become master.");
if (!isSendingStatus()) {
throw new IllegalStateException("Must be sending status updates to become the tempo master.");
}
// Is there someone we need to ask to yield to us?
final DeviceUpdate currentMaster = getTempoMaster();
if (currentMaster != null) {
// Send the yield request; we will become master when we get a successful response.
byte[] payload = new byte[MASTER_HANDOFF_REQUEST_PAYLOAD.length];
System.arraycopy(MASTER_HANDOFF_REQUEST_PAYLOAD, 0, payload, 0, MASTER_HANDOFF_REQUEST_PAYLOAD.length);
payload[2] = getDeviceNumber();
payload[8] = getDeviceNumber();
if (logger.isDebugEnabled()) {
logger.debug("Sending master yield request to player " + currentMaster);
}
requestingMasterRoleFromPlayer.set(currentMaster.deviceNumber);
assembleAndSendPacket(Util.PacketType.MASTER_HANDOFF_REQUEST, payload, currentMaster.address, BeatFinder.BEAT_PORT);
} else if (!master.get()) {
// There is no other master, we can just become it immediately.
requestingMasterRoleFromPlayer.set(0);
setMasterTempo(getTempo());
master.set(true);
}
}
/**
* Check whether we are currently in charge of the tempo and beat alignment.
*
* @return {@code true} if we hold the tempo master role
*/
public boolean isTempoMaster() {
return master.get();
}
/**
* Used to respond to master tempo changes and beats when we are synced, aligning our own metronome.
*/
private final MasterListener ourSyncMasterListener = new MasterListener() {
@Override
public void masterChanged(DeviceUpdate update) {
// We don’t care about this.
}
@Override
public void tempoChanged(double tempo) {
if (!isTempoMaster()) {
metronome.setTempo(tempo);
}
}
@Override
public void newBeat(Beat beat) {
if (!isTempoMaster()) {
metronome.setBeatPhase(0.0);
}
}
};
/**
* Indicates whether we are currently staying in sync with the tempo master. Will only be meaningful if we are
* sending status packets.
*/
private final AtomicBoolean synced = new AtomicBoolean(false);
/**
* Controls whether we are currently staying in sync with the tempo master. Will only be meaningful if we are
* sending status packets.
*
* @param sync if {@code true}, our status packets will be tempo and beat aligned with the tempo master
*/
public synchronized void setSynced(boolean sync) {
if (synced.get() != sync) {
// We are changing sync state, so add or remove our master listener as appropriate.
if (sync && isSendingStatus()) {
addMasterListener(ourSyncMasterListener);
} else {
removeMasterListener(ourSyncMasterListener);
}
// Also, if there is a tempo master, and we just got synced, adopt its tempo.
if (!isTempoMaster() && getTempoMaster() != null) {
setTempo(getMasterTempo());
}
}
synced.set(sync);
}
/**
* Check whether we are currently staying in sync with the tempo master. Will only be meaningful if we are
* sending status packets.
*
* @return {@code true} if our status packets will be tempo and beat aligned with the tempo master
*/
public boolean isSynced() {
return synced.get();
}
/**
* Indicates whether we believe our channel is currently on the air (audible in the mixer output). Will only
* be meaningful if we are sending status packets.
*/
private final AtomicBoolean onAir = new AtomicBoolean(false);
/**
* Change whether we believe our channel is currently on the air (audible in the mixer output). Only meaningful
* if we are sending status packets. If there is a real DJM mixer on the network, it will rapidly override any
* value established by this method with its actual report about the channel state.
*
* @param audible {@code true} if we should report ourselves as being on the air in our status packets
*/
public void setOnAir(boolean audible) {
onAir.set(audible);
}
/**
* Checks whether we believe our channel is currently on the air (audible in the mixer output). Only meaningful
* if we are sending status packets. If there is a real DJM mixer on the network, it will be controlling the state
* of this property.
*
* @return audible {@code true} if we should report ourselves as being on the air in our status packets
*/
public boolean isOnAir() {
return onAir.get();
}
/**
* Controls the tempo at which we report ourselves to be playing. Only meaningful if we are sending status packets.
* If {@link #isSynced()} is {@code true} and we are not the tempo master, any value set by this method will
* overridden by the the next tempo master change.
*
* @param bpm the tempo, in beats per minute, that we should report in our status and beat packets
*/
public void setTempo(double bpm) {
if (bpm == 0.0) {
throw new IllegalArgumentException("Tempo cannot be zero.");
}
final double oldTempo = metronome.getTempo();
metronome.setTempo(bpm);
notifyBeatSenderOfChange();
if (isTempoMaster() && (Math.abs(bpm - oldTempo) > getTempoEpsilon())) {
deliverTempoChangedAnnouncement(bpm);
}
}
/**
* Check the tempo at which we report ourselves to be playing. Only meaningful if we are sending status packets.
*
* @return the tempo, in beats per minute, that we are reporting in our status and beat packets
*/
public double getTempo() {
return metronome.getTempo();
}
/**
* The longest beat we will report playing; if we are still playing and reach this beat, we will loop back to beat
* one. If we are told to jump to a larger beat than this, we map it back into the range we will play. This would
* be a little over nine hours at 120 bpm, which seems long enough for any track.
*/
public final int MAX_BEAT = 65536;
/**
* Used to keep our beat number from growing indefinitely; we wrap it after a little over nine hours of playback;
* maybe we are playing a giant loop?
*/
private int wrapBeat(int beat) {
if (beat <= MAX_BEAT) {
return beat;
}
// This math is a little funky because beats are one-based rather than zero-based.
return ((beat - 1) % MAX_BEAT) + 1;
}
/**
* Moves our current playback position to the specified beat; this will be reflected in any status and beat packets
* that we are sending. An incoming value less than one will jump us to the first beat.
*
* @param beat the beat that we should pretend to be playing
*/
public synchronized void jumpToBeat(int beat) {
if (beat < 1) {
beat = 1;
} else {
beat = wrapBeat(beat);
}
if (playing.get()) {
metronome.jumpToBeat(beat);
} else {
whereStopped.set(metronome.getSnapshot(metronome.getTimeOfBeat(beat)));
}
}
/**
* Used in the process of handing off the tempo master role to another player.
*/
private final AtomicInteger syncCounter = new AtomicInteger(1);
/**
* Tracks the largest sync counter we have seen on the network, used in the process of handing off the tempo master
* role to another player.
*/
private final AtomicInteger largestSyncCounter = new AtomicInteger(1);
/**
* Used in the process of handing off the tempo master role to another player. Usually has the value 0xff, meaning
* no handoff is taking place. But when we are in the process of handing off the role, will hold the device number
* of the player that is taking over as tempo master.
*/
private final AtomicInteger nextMaster = new AtomicInteger(0xff);
/**
* Used in the process of being handed the tempo master role from another player. Usually has the value 0, meaning
* no handoff is taking place. But when we have received a successful yield response, will hold the device number
* of the player that is yielding to us, so we can watch for the next stage in its status updates.
*/
private final AtomicInteger masterYieldedFrom = new AtomicInteger(0);
/**
* Keeps track of the number of status packets we send.
*/
private final AtomicInteger packetCounter = new AtomicInteger(0);
/**
* The template used to assemble a status packet when we are sending them.
*/
private final static byte[] STATUS_PAYLOAD = { 0x01,
0x04, 0x00, 0x00, (byte)0xf8, 0x00, 0x00, 0x01, 0x00, 0x00, 0x03, 0x01, 0x00, 0x00, 0x00, 0x00, 0x01, // 0x020
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, (byte)0xa0, 0x00, 0x00, 0x00, 0x00, 0x00, // 0x030
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // 0x040
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // 0x050
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x04, 0x04, 0x00, 0x00, 0x00, 0x04, // 0x060
0x00, 0x00, 0x00, 0x04, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x31, 0x2e, 0x34, 0x33, // 0x070
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, (byte)0xff, 0x00, 0x00, 0x10, 0x00, 0x00, // 0x080
(byte)0x80, 0x00, 0x00, 0x00, 0x7f, (byte)0xff, (byte)0xff, (byte)0xff, 0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // 0x090
0x00, 0x00, 0x00, 0x00, 0x01, (byte)0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // 0x0a0
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // 0x0b0
0x00, 0x10, 0x00, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0f, 0x01, 0x00, 0x00, // 0x0c0
0x12, 0x34, 0x56, 0x78, 0x00, 0x00, 0x00, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, // 0x0d0
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // 0x0e0
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // 0x0f0
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // 0x100
0x00, 0x00, 0x00, 0x15, 0x00, 0x00, 0x07, 0x61, 0x00, 0x00, 0x06, 0x2f // 0x110
};
/**
* Gets the current playback position, then checks if we are within {@link BeatSender#SLEEP_THRESHOLD} ms before
* an upcoming beat, or {@link BeatSender#BEAT_THRESHOLD} ms after one, sleeping until that is no longer the case.
*
* @return the current playback position, potentially after having delayed a bit so that is not too near a beat
*/
private Snapshot avoidBeatPacket() {
Snapshot playState = getPlaybackPosition();
double distance = playState.distanceFromBeat();
while (playing.get() &&
(((distance < 0.0) && (Math.abs(distance) <= BeatSender.SLEEP_THRESHOLD)) ||
((distance >= 0.0) && (distance <= (BeatSender.BEAT_THRESHOLD + 1))))) {
try {
Thread.sleep(2);
} catch (InterruptedException e) {
logger.warn("Interrupted while sleeping to avoid beat packet; ignoring.", e);
}
playState = getPlaybackPosition();
distance = playState.distanceFromBeat();
}
return playState;
}
/**
* Send a status packet to all devices on the network. Used when we are actively sending status, presumably so we
* we can be the tempo master. Avoids sending one within twice {@link BeatSender#BEAT_THRESHOLD} milliseconds of
* a beat, to make sure the beat packet announces the new beat before an early status packet confuses matters.
*/
private void sendStatus() {
final Snapshot playState = avoidBeatPacket();
final boolean playing = this.playing.get();
byte[] payload = new byte[STATUS_PAYLOAD.length];
System.arraycopy(STATUS_PAYLOAD, 0, payload, 0, STATUS_PAYLOAD.length);
payload[0x02] = getDeviceNumber();
payload[0x05] = payload[0x02];
payload[0x08] = (byte)(playing ? 1 : 0); // a, playing flag
payload[0x09] = payload[0x02]; // Dr, the player from which the track was loaded
payload[0x5c] = (byte)(playing ? 3 : 5); // P1, playing flag
Util.numberToBytes(syncCounter.get(), payload, 0x65, 4);
payload[0x6a] = (byte)(0x84 + // F, main status bit vector
(playing ? 0x40 : 0) + (master.get() ? 0x20 : 0) + (synced.get() ? 0x10 : 0) + (onAir.get() ? 0x08 : 0));
payload[0x6c] = (byte)(playing ? 0x7a : 0x7e); // P2, playing flag
Util.numberToBytes((int)Math.round(getTempo() * 100), payload, 0x73, 2);
payload[0x7e] = (byte)(playing ? 9 : 1); // P3, playing flag
payload[0x7f] = (byte)(master.get() ? 1 : 0); // Mm, tempo master flag
payload[0x80] = (byte)nextMaster.get(); // Mh, tempo master handoff indicator
Util.numberToBytes((int)playState.getBeat(), payload, 0x81, 4);
payload[0x87] = (byte)(playState.getBeatWithinBar());
Util.numberToBytes(packetCounter.incrementAndGet(), payload, 0xa9, 4);
DatagramPacket packet = Util.buildPacket(Util.PacketType.CDJ_STATUS,
ByteBuffer.wrap(announcementBytes, DEVICE_NAME_OFFSET, DEVICE_NAME_LENGTH).asReadOnlyBuffer(),
ByteBuffer.wrap(payload));
packet.setPort(UPDATE_PORT);
for (DeviceAnnouncement device : DeviceFinder.getInstance().getCurrentDevices()) {
packet.setAddress(device.getAddress());
try {
socket.get().send(packet);
} catch (IOException e) {
logger.warn("Unable to send status packet to " + device, e);
}
}
}
/**
* Holds the singleton instance of this class.
*/
private static final VirtualCdj ourInstance = new VirtualCdj();
/**
* Get the singleton instance of this class.
*
* @return the only instance of this class which exists
*/
public static VirtualCdj getInstance() {
return ourInstance;
}
/**
* Register any relevant listeners; private to prevent instantiation.
*/
private VirtualCdj() {
masterTempo.set(Double.doubleToLongBits(0.0)); // Note that we have no master tempo yet.
// Arrange to have our status accurately reflect any relevant updates and commands from the mixer.
BeatFinder.getInstance().addOnAirListener(new OnAirListener() {
@Override
public void channelsOnAir(Set audibleChannels) {
setOnAir(audibleChannels.contains((int)getDeviceNumber()));
}
});
BeatFinder.getInstance().addFaderStartListener(new FaderStartListener() {
@Override
public void fadersChanged(Set playersToStart, Set playersToStop) {
if (playersToStart.contains((int)getDeviceNumber())) {
setPlaying(true);
} else if (playersToStop.contains((int)getDeviceNumber())) {
setPlaying(false);
}
}
});
BeatFinder.getInstance().addSyncListener(new SyncListener() {
@Override
public void setSyncMode(boolean synced) {
setSynced(synced);
}
@Override
public void becomeMaster() {
logger.debug("Received packet telling us to become master.");
if (isSendingStatus()) {
new Thread(new Runnable() {
@Override
public void run() {
try {
becomeTempoMaster();
} catch (Throwable t) {
logger.error("Problem becoming tempo master in response to sync command packet", t);
}
}
}).start();
} else {
logger.warn("Ignoring sync command to become tempo master, since we are not sending status packets.");
}
}
});
BeatFinder.getInstance().addMasterHandoffListener(new MasterHandoffListener() {
@Override
public void yieldMasterTo(int deviceNumber) {
if (logger.isDebugEnabled()) {
logger.debug("Received instruction to yield master to device " + deviceNumber);
}
if (isTempoMaster()) {
if (isSendingStatus() && getDeviceNumber() != deviceNumber) {
nextMaster.set(deviceNumber);
final DeviceUpdate lastStatusFromNewMaster = getLatestStatusFor(deviceNumber);
if (lastStatusFromNewMaster == null) {
logger.warn("Unable to send master yield response to device " + deviceNumber + ": no status updates have been received from it!");
} else {
byte[] payload = new byte[YIELD_ACK_PAYLOAD.length];
System.arraycopy(YIELD_ACK_PAYLOAD, 0, payload, 0, YIELD_ACK_PAYLOAD.length);
payload[0x02] = getDeviceNumber();
payload[0x08] = getDeviceNumber();
try {
assembleAndSendPacket(Util.PacketType.MASTER_HANDOFF_RESPONSE, payload, lastStatusFromNewMaster.getAddress(), UPDATE_PORT);
} catch (Throwable t) {
logger.error("Problem sending master yield acknowledgment to player " + deviceNumber, t);
}
}
}
} else {
logger.warn("Ignoring instruction to yield master to device " + deviceNumber + ": we were not tempo master.");
}
}
@Override
public void yieldResponse(int deviceNumber, boolean yielded) {
if (logger.isDebugEnabled()) {
logger.debug("Received yield response of " + yielded + " from device " + deviceNumber);
}
if (yielded) {
if (isSendingStatus()) {
if (deviceNumber == requestingMasterRoleFromPlayer.get()) {
requestingMasterRoleFromPlayer.set(0);
masterYieldedFrom.set(deviceNumber);
} else {
if (requestingMasterRoleFromPlayer.get() == 0) {
logger.warn("Ignoring master yield response from player " + deviceNumber +
" because we are not trying to become tempo master.");
} else {
logger.warn("Ignoring master yield response from player " + deviceNumber +
" because we asked player " + requestingMasterRoleFromPlayer.get());
}
}
} else {
logger.warn("Ignoring master yield response because we are not sending status.");
}
} else {
logger.warn("Ignoring master yield response with unexpected non-yielding value.");
}
}
});
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append("VirtualCdj[number:").append(getDeviceNumber()).append(", name:").append(getDeviceName());
sb.append(", announceInterval:").append(getAnnounceInterval());
sb.append(", useStandardPlayerNumber:").append(getUseStandardPlayerNumber());
sb.append(", tempoEpsilon:").append(getTempoEpsilon()).append(", active:").append(isRunning());
if (isRunning()) {
sb.append(", localAddress:").append(getLocalAddress().getHostAddress());
sb.append(", broadcastAddress:").append(getBroadcastAddress().getHostAddress());
sb.append(", latestStatus:").append(getLatestStatus()).append(", masterTempo:").append(getMasterTempo());
sb.append(", tempoMaster:").append(getTempoMaster());
sb.append(", isSendingStatus:").append(isSendingStatus());
if (isSendingStatus()) {
sb.append(", isSynced:").append(isSynced());
sb.append(", isTempoMaster:").append(isTempoMaster());
sb.append(", isPlaying:").append((isPlaying()));
sb.append(", isOnAir:").append(isOnAir());
sb.append(", metronome:").append(metronome);
}
}
return sb.append("]").toString();
}
}