Skip to content

Commit

Permalink
Initial release.
Browse files Browse the repository at this point in the history
  • Loading branch information
James Elliott authored and James Elliott committed Mar 21, 2017
1 parent 448a3c9 commit eb4cf4c
Show file tree
Hide file tree
Showing 5 changed files with 289 additions and 28 deletions.
10 changes: 7 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,15 @@ All notable changes to this project will be documented in this file.
This change log follows the conventions of
[keepachangelog.com](http://keepachangelog.com/).

## [Unreleased]
## [Unreleased][unreleased]

Nothing so far.

## 0.1.0 - 2017-03-20

### Added

- Started project.
- Intitial Release.

[Unreleased]: https://github.com/brunchboy/beat-carabiner/compare/0.0.0...HEAD
[Unreleased]: https://github.com/brunchboy/beat-carabiner/compare/v0.1.0...HEAD

40 changes: 34 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,43 @@ session and an Ableton Link session. It is designed for headless,
unattended operation so that it can be run on hardware like the
Raspberry Pi.

As long as beat-carabiner is running, has an active connection to
[Carabiner](https://github.com/brunchboy/carabiner#carabiner), and
sees an active Pro DJ Link Network (using its embedded copy of
[beat-link](https://github.com/brunchboy/beat-link#beat-link), it will
slave the Ableton Link tempo and beat grid to match the Pioneer gear.

## Installation

> :wrench: This will be documented once the first release is ready.
Install [Carabiner](https://github.com/brunchboy/carabiner#carabiner),
a Java runtime, and the latest `beat-carabiner.jar` from the
[releases](https://github.com/brunchboy/beat-carabiner/releases) page
on your target hardware.

You may be able to get by with Java 6, but a current release will
perform better and have more recent security updates.

You can either start Carabiner and beat-carabiner manually when you
want to use them, or configure them to start when your system boots.

## Usage

Install [Carabiner](https://github.com/brunchboy/carabiner#carabiner),
a Java runtime, and this project on your target hardware, start
Carabiner, and then run this as well.
To start beat-carabiner manually, run:

$ java -jar beat-carabiner.jar [args]
$ java -jar beat-carabiner.jar

It will log to the terminal window in which you are running it. If you
instead want to run it at system startup, you will probably also want
to set a log-file path, so it logs to a rotated log file in your
standard system logs directory, something like:

$ java -jar beat-carabiner.jar -L /var/log/beat-carabiner.log

Other options allow you to specify whether it should align to beats
instead of whole bars, the port on which it should contact the
Carabiner daemon, and how many milliseconds of latency it takes for
beat packets from the CDJs to arrive and be processed (you can tweak
this until you get good-sounding synchronization if necessary):

## Options

Expand All @@ -35,7 +61,9 @@ Carabiner, and then run this as well.

## Examples

...
Run without synchronizing bars, with a packet latency of 35 milliseconds:

$ java -jar beat-carabiner.jar -b --latency 35

## License

Expand Down
2 changes: 1 addition & 1 deletion project.clj
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
(defproject beat-carabiner "0.1.0-SNAPSHOT"
(defproject beat-carabiner "0.1.0"
:description "A minimal tempo bridge between Pioneer Pro DJ Link and Ableton Link."
:url "http://example.com/FIXME"
:license {:name "Eclipse Public License"
Expand Down
179 changes: 179 additions & 0 deletions src/beat_carabiner/carabiner.clj
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
(ns beat-carabiner.carabiner
"Maintains the connection with the local Carabiner daemon to
participate in an Ableton Link session. Based on the version in
beat-link-trigger, but simpler because it has no need to be
asynchronous, nor to support graceful shutdown, because as a
single-purpose daemon ourselves, we will run until terminated."
(:require [taoensso.timbre :as timbre])
(:import [java.net Socket InetSocketAddress]))

(defonce ^{:private true
:doc "When connected, holds the socket that can be used to
send messages to Carabiner, the configured latency value, and values
which track the peer count and tempo reported by the Ableton Link
session and the target tempo we are trying to maintain (when
applicable)."}
client (atom {}))

(def bpm-tolerance
"The amount by which the Link tempo can differ from our target tempo
without triggering an adjustment."
0.00001)

(def skew-tolerance
"The amount by which the start of a beat can be off without
triggering an adjustment. This can't be larger than the normal beat
packet jitter without causing spurious readjustments."
0.0166)

(def connect-timeout
"How long the connection attempt to the Carabiner daemon can take
before we give up on being able to reach it."
5000)

(defn- send-message
"Sends a message to the active Carabiner daemon, if we are
connected."
([message]
(when-let [socket (:socket @client)]
(send-message socket message)))
([socket message]
(.write (.getOutputStream socket) (.getBytes (str message) "UTF-8"))))

(defn- check-tempo
"If we are supposed to lock the Link tempo, make sure the Link
tempo is close enough to our target value, and adjust it if needed."
[]
(let [state @client]
(when (and (some? (:target-bpm state))
(> (Math/abs (- (:link-bpm state 0.0) (:target-bpm state))) bpm-tolerance))
(send-message (str "bpm " (:target-bpm state))))))

(defn- handle-status
"Processes a status update from Carabiner."
[status]
(let [bpm (double (:bpm status))
peers (int (:peers status))]
(swap! client assoc :link-bpm bpm :link-peers peers))
(check-tempo))

(defn- handle-beat-at-time
"Processes a beat probe response from Carabiner."
[socket info]
(let [raw-beat (Math/round (:beat info))
beat-skew (mod (:beat info) 1.0)
[time beat-number] (:beat @client)
candidate-beat (if (and beat-number (= time (:when info)))
(let [bar-skew (- (dec beat-number) (mod raw-beat 4))
adjustment (if (<= bar-skew -2) (+ bar-skew 4) bar-skew)]
(+ raw-beat adjustment))
raw-beat)
target-beat (if (neg? candidate-beat) (+ candidate-beat 4) candidate-beat)]
(when (or (> (Math/abs beat-skew) skew-tolerance)
(not= target-beat raw-beat))
(timbre/info "Realigning Ableton Link to beat" target-beat "by" beat-skew)
(send-message socket (str "force-beat-at-time " target-beat " " (:when info) " 4.0")))))

(defn- response-handler
"A loop that reads messages from Carabiner as long as it has a
connection, and takes appropriate action."
[socket]
(timbre/info "Connected to Carabiner.")
(try
(let [buffer (byte-array 1024)
input (.getInputStream socket)]
(while (not (.isClosed socket))
(try
(let [n (.read input buffer)]
(if (pos? n) ; We got data
(let [message (String. buffer 0 n "UTF-8")
reader (java.io.PushbackReader. (clojure.java.io/reader (.getBytes message "UTF-8")))
cmd (clojure.edn/read reader)]
(timbre/debug "Received:" message)
(case cmd
status (handle-status (clojure.edn/read reader))
beat-at-time (handle-beat-at-time socket (clojure.edn/read reader))
(timbre/error "Unrecognized message from Carabiner:" message)))
(do ; We read zero, means the other side closed; force our loop to terminate.
(timbre/warn "Carabiner unexpectedly closed our connection; is it still running?")
(.close socket))))
(catch java.net.SocketTimeoutException e
(timbre/info "Read from Carabiner timed out, did not expect this to happen."))
(catch Exception e
(timbre/error e "Problem reading from Carabiner.")))))
(catch Exception e
(timbre/error e "Problem managing Carabiner read loop."))
(finally
(.close socket) ; In case we got here through an exception
(swap! client dissoc :socket :link-bpm :link-peers)
(timbre/info "Ending read loop from Carabiner."))))

(defn connect
"Try to establish a connection to Carabiner, and run a loop to
process any responses from it. Will return only upon failure, or if
the connection has closed. Sets up a background thread to reject the
connection if we have not received an initial status report from the
Carabiner daemon within a second of opening it."
[port latency]
(try
(let [socket (Socket.)]
(try
(.connect socket (InetSocketAddress. "127.0.0.1" port) connect-timeout)
(swap! client assoc :socket socket :latency latency)
(future
(Thread/sleep 1000)
(when-not (:link-bpm @client)
(timbre/warn "Did not receive inital status packet from Carabiner daemon; disconnecting.")
(.close socket)))
(catch Exception e
(timbre/warn e "Unable to connect to Carabiner.")))
(when (.isConnected socket) (response-handler socket)))
(catch Exception e
(timbre/warn e "Problem running Carabiner response handler loop."))))

(defn valid-tempo?
"Checks whether a tempo request is a reasonable number of beats per
minute. Link supports the range 20 to 999 BPM. If you want something
outside that range, pick the closest multiple or fraction; for
example for 15 BPM, propose 30 BPM."
[bpm]
(< 20.0 bpm 999.0))

(defn- validate-tempo
"Makes sure a tempo request is a reasonable number of beats per
minute. Coerces it to a double value if it is in the legal Link
range, otherwise throws an exception."
[bpm]
(if (valid-tempo? bpm)
(double bpm)
(throw (IllegalArgumentException. "Tempo must be between 20 and 999 BPM"))))

(defn lock-tempo
"Starts holding the tempo of the Link session to the specified
number of beats per minute."
[bpm]
(timbre/info "Locking Ableton Link tempo to" bpm)
(swap! client assoc :target-bpm (validate-tempo bpm))
(check-tempo))

(defn unlock-tempo
"Allow the tempo of the Link session to be controlled by other
participants."
[]
(timbre/info "Unlocking Ableton Link tempo.")
(swap! client dissoc :target-bpm))

(defn beat-at-time
"Find out what beat falls at the specified time in the Link
timeline, assuming 4 beats per bar since we are dealing with Pro DJ
Link, and taking into account the configured latency. When the
response comes, nudge the Link timeline so that it had a beat at the
same time. If a beat-number (ranging from 1 to the quantum) is
supplied, move the timeline by more than a beat if necessary in
order to get the Link session's bars aligned as well."
([time]
(beat-at-time time nil))
([time beat-number]
(let [adjusted-time (- time (* (:latency @client) 1000))]
(swap! client assoc :beat [adjusted-time beat-number])
(send-message (str "beat-at-time " adjusted-time " 4.0")))))
86 changes: 68 additions & 18 deletions src/beat_carabiner/core.clj
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
(ns beat-carabiner.core
"The main entry point for the beat-carabiner daemon. Handles any
command-line arguments, then establishes and interacts with
connections to any Pioneer Pro DJ Link and Ableton Link sessions
that can be found."
(:require [clojure.tools.cli :as cli]
[beat-carabiner.carabiner :as carabiner]
[taoensso.timbre.appenders.3rd-party.rotor :as rotor]
[taoensso.timbre :as timbre])
(:import [org.deepsymmetry.beatlink DeviceFinder DeviceAnnouncementListener])
(:import [org.deepsymmetry.beatlink DeviceFinder DeviceAnnouncementListener BeatFinder
VirtualCdj MasterListener DeviceUpdateListener])
(:gen-class))

(defn- create-appenders
Expand All @@ -19,6 +25,24 @@
logging to stdout."}
appenders (atom {:println (timbre/println-appender {:stream :auto})}))

(defn output-fn
"Log format (fn [data]) -> string output fn.
You can modify default options with `(partial output-fn
<opts-map>)`. This is based on timbre's default, but removes the
hostname and stack trace fonts."
([data] (output-fn nil data))
([{:keys [no-stacktrace?] :as opts} data]
(let [{:keys [level ?err_ vargs_ msg_ ?ns-str hostname_
timestamp_ ?line]} data]
(str
@timestamp_ " "
(clojure.string/upper-case (name level)) " "
"[" (or ?ns-str "?") ":" (or ?line "?") "] - "
(force msg_)
(when-not no-stacktrace?
(when-let [err (force ?err_)]
(str "\n" (timbre/stacktrace err (assoc opts :stacktrace-fonts {})))))))))

(defn- init-logging-internal
"Performs the actual initialization of the logging environment,
protected by the delay below to insure it happens only once."
Expand All @@ -38,7 +62,7 @@
:locale :jvm-default
:timezone (java.util.TimeZone/getDefault)}

:output-fn timbre/default-output-fn ; (fn [data]) -> string
:output-fn output-fn ; (fn [data]) -> string
})

;; Install the desired log appenders, if they have been configured
Expand Down Expand Up @@ -160,24 +184,50 @@
(reset! appenders (create-appenders log-file)))
(init-logging)

(timbre/info "Looking for Carabiner on port" (:carabiner-port options))
(timbre/info (if (:beat-align options)
"Will align Ableton Link session at the level of individual beats, ignoring bars."
"Will align Ableton Link session to bars and beats."))

;; Start the daemons that do everything!
(DeviceFinder/start)
(DeviceFinder/addDeviceAnnouncementListener
(let [bar-align (not (:beat-align options))]
(VirtualCdj/addMasterListener ; First set up to respond to master tempo changes and beats.
(reify MasterListener
(masterChanged [_ update]
#_(timbre/info "Master Changed!" update)
(when (nil? update) ; If there's no longer a tempo master,
(carabiner/unlock-tempo))) ; free the Ableton Link session tempo.
(tempoChanged [_ tempo] ; Master tempo has changed, lock the Ableton Link session to it, unless out of range.
(if (carabiner/valid-tempo? tempo)
(carabiner/lock-tempo tempo)
(carabiner/unlock-tempo)))
(newBeat [_ beat] ; The master player has reported a beat, so align to it as needed.
#_(timbre/info "Beat!" beat)
(carabiner/beat-at-time (long (/ (.getTimestamp beat) 1000)) (when bar-align (.getBeatWithinBar beat)))))))

(timbre/info "Waiting for Pro DJ Link devices...")
(DeviceFinder/start) ; Start watching for any Pro DJ Link devices.
(DeviceFinder/addDeviceAnnouncementListener ; And set up to respond when they arrive and leave.
(reify DeviceAnnouncementListener
(deviceFound [_ announcement]
(timbre/info "Device Found:" announcement))
(timbre/info "Pro DJ Link Device Found:" announcement)
(future ; We have seen a device, so we can start up the Virtual CDJ if it's not running.
(if (VirtualCdj/start)
(timbre/info "Virtual CDJ running as Player" (VirtualCdj/getDeviceNumber))
(timbre/warn "Virtual CDJ failed to start."))))
(deviceLost [_ announcement]
(timbre/info "Device Lost:" announcement))))

;; TODO: Whenever we have at least one Pioneer device, start up the virtual CDJ. Shut it down again when we
;; lose the last one.

;; TODO: Try to open a connection to the Carabiner daemon; if we fail, sleep for a while, and try again. Same
;; if it ever closes on us.

;; TODO: When we have both a Virtual CDJ and a Carabiner daemon, tie the tempo of the Carabiner session to the
;; master player on the Pioneer network. See carabiner.clj in beat-link-trigger. Also respond to beat
;; packets from the master player, aligning at the beat or bar level as we are configured.
(timbre/info "Startup complete.")))
(timbre/info "Pro DJ Link Device Lost:" announcement)
(when (empty? (DeviceFinder/currentDevices))
(timbre/info "Shutting down Virtual CDJ.") ; We have lost the last device, so shut down for now.
(VirtualCdj/stop)
(carabiner/unlock-tempo)))))

(BeatFinder/start) ; Also start watching for beats, so the beat-alignment handler will get called.

;; Enter an infinite loop attempting to connect to the Carabiner daemon.
(loop [port (:carabiner-port options)
latency (:latency options)]
(timbre/info "Trying to connect to Carabiner daemon on port" port "with latency" latency)
(carabiner/connect port latency)
(timbre/warn "Not connected to Carabiner. Waiting ten seconds to try again.")
(Thread/sleep 10000)
(recur port latency))))

0 comments on commit eb4cf4c

Please sign in to comment.