org.deepsymmetry.beatlink.BeatFinder Maven / Gradle / Ivy
Show all versions of beat-link Show documentation
package org.deepsymmetry.beatlink;
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.SocketException;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
import java.util.TreeSet;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicReference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Watches for devices to report new beats by broadcasting beat packets on port 50001,
* and passes them on to registered listeners. When players are actively playing music,
* they send beat packets at the start of each beat which, in addition to announcing the
* start of the beat, provide information about where the beat falls within a measure of
* music (assuming that the track was properly configured in rekordbox, and is in 4/4 time),
* the current BPM of the track being played, and the current player pitch adjustment, from
* which the actual effective BPM can be calculated.
*
* When players are stopped, they do not send beat packets, but the mixer continues sending them
* at the last BPM reported by the master player, so it acts as the most reliable synchronization
* source. The mixer does not make any effort to keep its notion of measures (down beats) consistent
* with any player, however. So systems which want to stay in sync with measures as well as beats
* will want to use the {@link VirtualCdj} to maintain awareness of which player is the master player.
*
* This class also receives special sync and on-air control messages which are sent to the same port as
* beat packets. If the {@link VirtualCdj} is sending status packets, it needs to be notified about these
* so it can properly update its sync and on-air state if the mixer tells it to, so it will ensure the
* {@code BeatFinder} is running whenever it is sending status updates.
*
* @author James Elliott
*/
@SuppressWarnings("WeakerAccess")
public class BeatFinder extends LifecycleParticipant {
private static final Logger logger = LoggerFactory.getLogger(BeatFinder.class);
/**
* The port to which devices broadcast beat messages.
*/
public static final int BEAT_PORT = 50001;
/**
* The socket used to listen for beat packets while we are active.
*/
private final AtomicReference socket = new AtomicReference(null);
/**
* Check whether we are presently listening for beat packets.
*
* @return {@code true} if our socket is open and monitoring for DJ Link beat packets on the network
*/
public boolean isRunning() {
return socket.get() != null;
}
/**
* Helper method to check that we got the right size packet.
*
* @param packet a packet that has been received
* @param expectedLength the number of bytes we expect it to contain
* @param name the description of the packet in case we need to report issues with the length
*
* @return {@code true} if enough bytes were received to process the packet
*/
private boolean isPacketLongEnough(DatagramPacket packet, int expectedLength, String name) {
final int length = packet.getLength();
if (length < expectedLength) {
logger.warn("Ignoring too-short " + name + " packet; expecting " + expectedLength + " bytes and got " +
length + ".");
return false;
}
if (length > expectedLength) {
logger.warn("Processing too-long " + name + " packet; expecting " + expectedLength +
" bytes and got " + length + ".");
}
return true;
}
/**
* Start listening for beat announcements and sync commands. If already listening, has no effect.
*
* @throws SocketException if the socket to listen on port 50001 cannot be created
*/
public synchronized void start() throws SocketException {
if (!isRunning()) {
socket.set(new DatagramSocket(BEAT_PORT));
deliverLifecycleAnnouncement(logger, true);
final byte[] buffer = new byte[512];
final DatagramPacket packet = new DatagramPacket(buffer, buffer.length);
Thread receiver = new Thread(null, new Runnable() {
@Override
public void run() {
boolean received;
while (isRunning()) {
try {
socket.get().receive(packet);
received = !DeviceFinder.getInstance().isAddressIgnored(packet.getAddress());
} 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 DeviceAnnouncement socket, stopping", e);
stop();
}
received = false;
}
try {
if (received) {
final Util.PacketType kind = Util.validateHeader(packet, BEAT_PORT);
if (kind != null) {
switch (kind) {
case BEAT:
if (isPacketLongEnough(packet, 96, "beat")) {
deliverBeat(new Beat(packet));
}
break;
case CHANNELS_ON_AIR:
if (isPacketLongEnough(packet, 0x2d, "channels on-air")) {
byte[] data = packet.getData();
Set audibleChannels = new TreeSet();
for (int channel = 1; channel <= 4; channel++) {
if (data[0x23 + channel] != 0) {
audibleChannels.add(channel);
}
}
audibleChannels = Collections.unmodifiableSet(audibleChannels);
deliverOnAirUpdate(audibleChannels);
}
break;
case SYNC_CONTROL:
if (isPacketLongEnough(packet, 0x2c, "sync control command")) {
deliverSyncCommand(packet.getData()[0x2b]);
}
break;
case MASTER_HANDOFF_REQUEST:
if (isPacketLongEnough(packet, 0x28, "tempo master handoff request")) {
deliverMasterYieldCommand(packet.getData()[0x21]);
}
break;
case MASTER_HANDOFF_RESPONSE:
if (isPacketLongEnough(packet, 0x2c, "tempo master handoff response")) {
byte[] data = packet.getData();
deliverMasterYieldResponse(data[0x21], data[0x2b] == 1);
}
break;
case FADER_START_COMMAND:
if (isPacketLongEnough(packet, 0x28, "fader start command")) {
byte[] data = packet.getData();
Set playersToStart = new TreeSet();
Set playersToStop = new TreeSet();
for (int channel = 1; channel <= 4; channel++) {
switch (data[0x23 + channel]) {
case 0:
playersToStart.add(channel);
break;
case 1:
playersToStop.add(channel);
break;
case 2:
// Leave this player alone
break;
default:
logger.warn("Ignoring unrecognized fader start command, " +
data[0x23 + channel] + ", for channel " + channel);
}
}
playersToStart = Collections.unmodifiableSet(playersToStart);
playersToStop = Collections.unmodifiableSet(playersToStop);
deliverFaderStartCommand(playersToStart, playersToStop);
}
default:
logger.warn("Ignoring packet received on beat port with unexpected type: " + kind);
}
}
}
} catch (Throwable t) {
logger.warn("Problem processing beat packet", t);
}
}
}
}, "beat-link BeatFinder receiver");
receiver.setDaemon(true);
receiver.setPriority(Thread.MAX_PRIORITY);
receiver.start();
}
}
/**
* Stop listening for beats.
*/
public synchronized void stop() {
if (isRunning()) {
socket.get().close();
socket.set(null);
deliverLifecycleAnnouncement(logger, false);
}
}
/**
* Keeps track of the registered beat listeners.
*/
private final Set beatListeners =
Collections.newSetFromMap(new ConcurrentHashMap());
/**
* Adds the specified beat listener to receive beat announcements when DJ Link devices broadcast
* them on the network. 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, beat announcements are delivered to listeners directly on the thread that is receiving them
* 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 beat announcements will back up.
* If you want to perform lengthy processing of any sort, do so on another thread.
*
* @param listener the beat listener to add
*/
public void addBeatListener(BeatListener listener) {
if (listener != null) {
beatListeners.add(listener);
}
}
/**
* Removes the specified beat listener so that it no longer receives beat announcements when
* DJ Link devices broadcast them to the network. 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 beat listener to remove
*/
public void removeBeatListener(BeatListener listener) {
if (listener != null) {
beatListeners.remove(listener);
}
}
/**
* Get the set of beat listeners that are currently registered.
*
* @return the currently registered beat listeners
*/
public Set getBeatListeners() {
// Make a copy so callers get an immutable snapshot of the current state.
return Collections.unmodifiableSet(new HashSet(beatListeners));
}
/**
* Send a beat announcement to all registered listeners, and let the {@link VirtualCdj} know about it in case it
* needs to notify the master beat listeners.
*
* @param beat the message announcing the new beat
*/
private void deliverBeat(final Beat beat) {
VirtualCdj.getInstance().processBeat(beat);
for (final BeatListener listener : getBeatListeners()) {
try {
listener.newBeat(beat);
} catch (Throwable t) {
logger.warn("Problem delivering beat announcement to listener", t);
}
}
}
/**
* Keeps track of the registered sync command listeners.
*/
private final Set syncListeners =
Collections.newSetFromMap(new ConcurrentHashMap());
/**
* Adds the specified sync command listener to receive sync commands when DJ Link devices send
* them to Beat Link. 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, sync commands are delivered to listeners directly on the thread that is receiving them
* 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 beat announcements will back up.
* If you want to perform lengthy processing of any sort, do so on another thread.
*
* @param listener the sync listener to add
*/
public void addSyncListener(SyncListener listener) {
if (listener != null) {
syncListeners.add(listener);
}
}
/**
* Removes the specified sync listener so that it no longer receives sync commands when
* DJ Link devices send them to Beat Link. 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 sync listener to remove
*/
public void removeSyncListener(SyncListener listener) {
if (listener != null) {
syncListeners.remove(listener);
}
}
/**
* Get the set of sync command listeners that are currently registered.
*
* @return the currently registered sync listeners
*/
public Set getSyncListeners() {
// Make a copy so callers get an immutable snapshot of the current state.
return Collections.unmodifiableSet(new HashSet(syncListeners));
}
/**
* Send a sync command to all registered listeners.
*
* @param command the byte which identifies the type of sync command we received
*/
private void deliverSyncCommand(byte command) {
for (final SyncListener listener : getSyncListeners()) {
try {
switch (command) {
case 0x01:
listener.becomeMaster();
case 0x10:
listener.setSyncMode(true);
break;
case 0x20:
listener.setSyncMode(false);
break;
}
} catch (Throwable t) {
logger.warn("Problem delivering sync command to listener", t);
}
}
}
/**
* Keeps track of the registered master handoff command listeners.
*/
private final Set masterHandoffListeners =
Collections.newSetFromMap(new ConcurrentHashMap());
/**
* Adds the specified master handoff listener to receive tempo master handoff commands when DJ Link devices send
* them to Beat Link. 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, handoff commands are delivered to listeners directly on the thread that is receiving them
* 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 beat announcements will back up.
* If you want to perform lengthy processing of any sort, do so on another thread.
*
* @param listener the tempo master handoff listener to add
*/
public void addMasterHandoffListener(MasterHandoffListener listener) {
if (listener != null) {
masterHandoffListeners.add(listener);
}
}
/**
* Removes the specified master handoff listener so that it no longer receives tempo master handoff commands when
* DJ Link devices send them to Beat Link. 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 tempo master handoff listener to remove
*/
public void removeMasterHandoffListener(MasterHandoffListener listener) {
if (listener != null) {
masterHandoffListeners.remove(listener);
}
}
/**
* Get the set of master handoff command listeners that are currently registered.
*
* @return the currently registered tempo master handoff command listeners
*/
public Set getMasterHandoffListeners() {
// Make a copy so callers get an immutable snapshot of the current state.
return Collections.unmodifiableSet(new HashSet(masterHandoffListeners));
}
/**
* Send a master handoff yield command to all registered listeners.
*
* @param toPlayer the device number to which we are being instructed to yield the tempo master role
*/
private void deliverMasterYieldCommand(int toPlayer) {
for (final MasterHandoffListener listener : getMasterHandoffListeners()) {
try {
listener.yieldMasterTo(toPlayer);
} catch (Throwable t) {
logger.warn("Problem delivering master yield command to listener", t);
}
}
}
/**
* Send a master handoff yield response to all registered listeners.
*
* @param fromPlayer the device number that is responding to our request that it yield the tempo master role to us
* @param yielded will be {@code true} if we should now be the tempo master
*/
private void deliverMasterYieldResponse(int fromPlayer, boolean yielded) {
for (final MasterHandoffListener listener : getMasterHandoffListeners()) {
try {
listener.yieldResponse(fromPlayer, yielded);
} catch (Throwable t) {
logger.warn("Problem delivering master yield response to listener", t);
}
}
}
/**
* Keeps track of the registered on-air listeners.
*/
private final Set onAirListeners =
Collections.newSetFromMap(new ConcurrentHashMap());
/**
* Adds the specified on-air listener to receive channel on-air updates when the mixer broadcasts
* them on the network. 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, on-air updates are delivered to listeners directly on the thread that is receiving them
* 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 beat announcements will back up.
* If you want to perform lengthy processing of any sort, do so on another thread.
*
* @param listener the on-air listener to add
*/
public void addOnAirListener(OnAirListener listener) {
if (listener != null) {
onAirListeners.add(listener);
}
}
/**
* Removes the specified on-air listener so that it no longer receives channel on-air updates when
* the mixer broadcasts them to the network. 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 on-air listener to remove
*/
public void removeOnAirListener(OnAirListener listener) {
if (listener != null) {
onAirListeners.remove(listener);
}
}
/**
* Get the set of on-air listeners that are currently registered.
*
* @return the currently registered on-air listeners
*/
public Set getOnAirListeners() {
// Make a copy so callers get an immutable snapshot of the current state.
return Collections.unmodifiableSet(new HashSet(onAirListeners));
}
/**
* Send a channels on-air update to all registered listeners.
*
* @param audibleChannels holds the device numbers of all channels that can currently be heard in the mixer output
*/
private void deliverOnAirUpdate(Set audibleChannels) {
for (final OnAirListener listener : getOnAirListeners()) {
try {
listener.channelsOnAir(audibleChannels);
} catch (Throwable t) {
logger.warn("Problem delivering channels on-air update to listener", t);
}
}
}
/**
* Keeps track of the registered fader start listeners.
*/
private final Set faderStartListeners =
Collections.newSetFromMap(new ConcurrentHashMap());
/**
* Adds the specified fader start listener to receive fader start commands when the mixer broadcasts
* them on the network. 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, fader start commands are delivered to listeners directly on the thread that is receiving them
* 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 beat announcements will back up.
* If you want to perform lengthy processing of any sort, do so on another thread.
*
* @param listener the fader start listener to add
*/
public void addFaderStartListener(FaderStartListener listener) {
if (listener != null) {
faderStartListeners.add(listener);
}
}
/**
* Removes the specified fader start listener so that it no longer receives fader start commands when
* the mixer broadcasts them to the network. 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 fader start listener to remove
*/
public void removeFaderStartListener(FaderStartListener listener) {
if (listener != null) {
faderStartListeners.remove(listener);
}
}
/**
* Get the set of fader start listeners that are currently registered.
*
* @return the currently registered fader start listeners
*/
public Set getFaderStartListeners() {
// Make a copy so callers get an immutable snapshot of the current state.
return Collections.unmodifiableSet(new HashSet(faderStartListeners));
}
/**
* Send a fader start command to all registered listeners.
*
* @param playersToStart contains the device numbers of all players that should start playing
* @param playersToStop contains the device numbers of all players that should stop playing
*/
private void deliverFaderStartCommand(Set playersToStart, Set playersToStop) {
for (final FaderStartListener listener : getFaderStartListeners()) {
try {
listener.fadersChanged(playersToStart, playersToStop);
} catch (Throwable t) {
logger.warn("Problem delivering fader start command to listener", t);
}
}
}
/**
* Holds the singleton instance of this class.
*/
private static final BeatFinder ourInstance = new BeatFinder();
/**
* Get the singleton instance of this class.
*
* @return the only instance of this class which exists.
*/
public static BeatFinder getInstance() {
return ourInstance;
}
/**
* Prevent direct instantiation.
*/
private BeatFinder() {
// Nothing to do.
}
@Override
public String toString() {
return "BeatFinder[active:" + isRunning() + "]";
}
}